From 04d134806ba2aeaafb4386d01db67406bfc90e48 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 11 Aug 2023 01:31:52 +0200 Subject: [PATCH 01/39] feat(server): wip tax rates service --- .../src/api/controllers/TaxRates/TaxRates.ts | 108 ++++++++++++++++++ .../src/api/controllers/TaxRates/index.ts | 0 .../20230810191606_create_tax_rates.js | 14 +++ packages/server/src/interfaces/TaxRate.ts | 48 ++++++++ packages/server/src/interfaces/index.ts | 1 + packages/server/src/loaders/tenantModels.ts | 2 + packages/server/src/models/TaxRate.ts | 40 +++++++ .../TaxRates/CommandTaxRatesValidators.ts | 16 +++ .../src/services/TaxRates/CreateTaxRate.ts | 53 +++++++++ .../src/services/TaxRates/DeleteTaxRate.ts | 54 +++++++++ .../src/services/TaxRates/EditTaxRate.ts | 68 +++++++++++ .../src/services/TaxRates/GetTaxRate.ts | 28 +++++ .../src/services/TaxRates/GetTaxRates.ts | 21 ++++ .../services/TaxRates/TaxRatesApplication.ts | 82 +++++++++++++ .../server/src/services/TaxRates/constants.ts | 3 + packages/server/src/subscribers/events.ts | 13 ++- 16 files changed, 550 insertions(+), 1 deletion(-) create mode 100644 packages/server/src/api/controllers/TaxRates/TaxRates.ts create mode 100644 packages/server/src/api/controllers/TaxRates/index.ts create mode 100644 packages/server/src/database/migrations/20230810191606_create_tax_rates.js create mode 100644 packages/server/src/interfaces/TaxRate.ts create mode 100644 packages/server/src/models/TaxRate.ts create mode 100644 packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts create mode 100644 packages/server/src/services/TaxRates/CreateTaxRate.ts create mode 100644 packages/server/src/services/TaxRates/DeleteTaxRate.ts create mode 100644 packages/server/src/services/TaxRates/EditTaxRate.ts create mode 100644 packages/server/src/services/TaxRates/GetTaxRate.ts create mode 100644 packages/server/src/services/TaxRates/GetTaxRates.ts create mode 100644 packages/server/src/services/TaxRates/TaxRatesApplication.ts create mode 100644 packages/server/src/services/TaxRates/constants.ts 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..d9752dede --- /dev/null +++ b/packages/server/src/api/controllers/TaxRates/TaxRates.ts @@ -0,0 +1,108 @@ +import { Inject, Service } from 'typedi'; +import { Router, Request, Response } from 'express'; +import { body, query } from 'express-validator'; +import { pick } from 'lodash'; +import { IOptionDTO, IOptionsDTO } from '@/interfaces'; +import BaseController from '@/api/controllers/BaseController'; +import asyncMiddleware from '@/api/middleware/asyncMiddleware'; +import { AbilitySubject, PreferencesAction } from '@/interfaces'; +import SettingsService from '@/services/Settings/SettingsService'; +import CheckPolicies from '@/api/middleware/CheckPolicies'; +import { TaxRatesApplication } from '@/services/TaxRates/TaxRatesApplication'; + +@Service() +export class TaxRatesController extends BaseController { + @Inject() + private taxRatesApplication: TaxRatesApplication; + + /** + * Router constructor. + */ + public router() { + const router = Router(); + + router.post( + '/', + this.taxRateValidationSchema, + this.validationResult, + asyncMiddleware(this.createTaxRate.bind(this)) + ); + router.post( + '/:tax_rate_id', + this.taxRateValidationSchema, + this.validationResult, + asyncMiddleware(this.editTaxRate.bind(this)) + ); + router.delete( + '/:tax_rate_id', + this.taxRateValidationSchema, + this.validationResult, + asyncMiddleware(this.deleteTaxRate.bind(this)) + ); + router.get( + '/:tax_rate_id', + this.taxRateValidationSchema, + this.validationResult, + asyncMiddleware(this.getTaxRate.bind(this)) + ); + router.get( + '/', + this.taxRateValidationSchema, + this.validationResult, + asyncMiddleware(this.getTaxRates.bind(this)) + ); + return router; + } + + /** + * Save settings validation schema. + */ + private get taxRateValidationSchema() { + return [ + body('rate').exists().isNumeric().toFloat(), + body('is_non_recoverable').exists().isBoolean().default(false), + ]; + } + + /** + * + * @param {Request} req - + * @param {Response} res - + */ + public async createTaxRate(req: Request, res: Response, next) { + const taxRate = await this.taxRatesApplication.createTaxRate() + } + + /** + * + * @param {Request} req - + * @param {Response} res - + */ + public async editTaxRate(req: Request, res: Response, next) { + const taxRate = await this.taxRatesApplication.editTaxRate(); + + } + + /** + * + * @param {Request} req - + * @param {Response} res - + */ + public async deleteTaxRate(req: Request, res: Response, next) { + await this.taxRatesApplication.deleteTaxRate(); + } + + /** + * + * @param {Request} req - + * @param {Response} res - + */ + public async getTaxRate(req: Request, res: Response, next) {} + + /** + * + * @param {Request} req - + * @param {Response} res - + */ + public async getTaxRates(req: Request, res: Response, next) {} +} diff --git a/packages/server/src/api/controllers/TaxRates/index.ts b/packages/server/src/api/controllers/TaxRates/index.ts new file mode 100644 index 000000000..e69de29bb 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..3cdbf1098 --- /dev/null +++ b/packages/server/src/database/migrations/20230810191606_create_tax_rates.js @@ -0,0 +1,14 @@ +exports.up = (knex) => { + return knex.schema.createTable('tax_rates', (table) => { + table.increments(); + table.string('name'); + table.decimal('rate'); + table.boolean('is_non_recoverable'); + table.boolean('is_compound'); + table.timestamps(); + }); +}; + +exports.down = (knex) => { + return knex.schema.dropTableIfExists('tax_rates'); +}; diff --git a/packages/server/src/interfaces/TaxRate.ts b/packages/server/src/interfaces/TaxRate.ts new file mode 100644 index 000000000..4514a732a --- /dev/null +++ b/packages/server/src/interfaces/TaxRate.ts @@ -0,0 +1,48 @@ +import { Knex } from 'knex'; + +export interface ITaxRate {} + +export interface ICommonTaxRateDTO { + name: string; + rate: number; + IsNonRecoverable: boolean; + IsCompound: 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 ITaxRateDeletedPayload { + oldTaxRate: ITaxRate; + tenantId: number; + trx: Knex.Transaction; +} 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/loaders/tenantModels.ts b/packages/server/src/loaders/tenantModels.ts index 5e07ff3dd..3c7865922 100644 --- a/packages/server/src/loaders/tenantModels.ts +++ b/packages/server/src/loaders/tenantModels.ts @@ -58,6 +58,7 @@ 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'; export default (knex) => { const models = { @@ -119,6 +120,7 @@ export default (knex) => { Project, Time, Task, + TaxRate, }; return mapValues(models, (model) => model.bindKnex(knex)); }; diff --git a/packages/server/src/models/TaxRate.ts b/packages/server/src/models/TaxRate.ts new file mode 100644 index 000000000..b137a24d2 --- /dev/null +++ b/packages/server/src/models/TaxRate.ts @@ -0,0 +1,40 @@ +import { mixin, Model, raw } from 'objection'; +import TenantModel from 'models/TenantModel'; +import ModelSearchable from './ModelSearchable'; + +export default class TaxRate extends mixin(TenantModel, [ModelSearchable]) { + /** + * Table name + */ + static get tableName() { + return 'tax_rates'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['created_at', 'updated_at']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return []; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return {}; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + return {}; + } +} diff --git a/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts b/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts new file mode 100644 index 000000000..1b25cabee --- /dev/null +++ b/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts @@ -0,0 +1,16 @@ +import { ServiceError } from '@/exceptions'; +import TaxRate from '@/models/TaxRate'; +import { Service } from 'typedi'; + +@Service() +export class CommandTaxRatesValidators { + /** + * + * @param {} taxRate + */ + public validateTaxRateExistance(taxRate: TaxRate | undefined | null) { + if (!taxRate) { + throw new ServiceError(ERRORS.TAX_RATE_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..1414b5654 --- /dev/null +++ b/packages/server/src/services/TaxRates/CreateTaxRate.ts @@ -0,0 +1,53 @@ +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'; + +@Service() +export class CreateTaxRate { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private uow: UnitOfWork; + + /** + * Creates a new tax rate. + * @param {number} tenantId + * @param {ICreateTaxRateDTO} createTaxRateDTO + */ + public createTaxRate(tenantId: number, createTaxRateDTO: ICreateTaxRateDTO) { + const { TaxRate } = this.tenancy.models(tenantId); + + 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).insert({ ...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..17f3a339e --- /dev/null +++ b/packages/server/src/services/TaxRates/DeleteTaxRate.ts @@ -0,0 +1,54 @@ +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; + + /** + * + * @param tenantId + * @param taxRateId + */ + public deleteTaxRate(tenantId: number, taxRateId: number) { + const { TaxRate } = this.tenancy.models(tenantId); + + const oldTaxRate = TaxRate.query().findById(taxRateId); + + this.validators.validateTaxRateExistance(oldTaxRate); + + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onSaleInvoiceCreating` event. + await this.eventPublisher.emitAsync(events.taxRates.onDeleting, { + oldTaxRate, + tenantId, + trx, + } as ITaxRateDeletingPayload); + + await TaxRate.query(trx).findById(taxRateId).delete(); + + // + 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..a6b502388 --- /dev/null +++ b/packages/server/src/services/TaxRates/EditTaxRate.ts @@ -0,0 +1,68 @@ +import { + IEditTaxRateDTO, + ITaxRateCreatingPayload, + ITaxRateEditedPayload, + ITaxRateEditingPayload, +} from '@/interfaces'; +import { Inject } 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'; + +export class EditTaxRateService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private uow: UnitOfWork; + + @Inject() + private validators: CommandTaxRatesValidators; + + /** + * + * @param {number} tenantId + * @param {number} taxRateId + * @param {IEditTaxRateDTO} taxRateEditDTO + */ + public editTaxRate( + tenantId: number, + taxRateId: number, + editTaxRateDTO: IEditTaxRateDTO + ) { + const { TaxRate } = this.tenancy.models(tenantId); + + const oldTaxRate = TaxRate.query().findById(taxRateId); + + this.validators.validateTaxRateExistance(oldTaxRate); + + return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { + // Triggers `onTaxRateCreating` event. + await this.eventPublisher.emitAsync(events.taxRates.onCreating, { + editTaxRateDTO, + tenantId, + trx, + } as ITaxRateEditingPayload); + + const taxRate = await TaxRate.query(trx) + .findById(taxRateId) + .patch({ ...editTaxRateDTO }); + + // Triggers `onTaxRateCreated` event. + await this.eventPublisher.emitAsync(events.taxRates.onCreated, { + 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..536741b3e --- /dev/null +++ b/packages/server/src/services/TaxRates/GetTaxRate.ts @@ -0,0 +1,28 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { CommandTaxRatesValidators } from './CommandTaxRatesValidators'; + +@Service() +export class GetTaxRateService { + @Inject() + private tenancy: HasTenancyService; + + @Inject() + private validators: CommandTaxRatesValidators; + + /** + * + * @param {number} tenantId + * @param {number} taxRateId + * @returns + */ + public async getTaxRate(tenantId: number, taxRateId: number) { + const { TaxRate } = this.tenancy.models(tenantId); + + const taxRate = await TaxRate.query().findById(taxRateId); + + this.validators.validateTaxRateExistance(taxRate); + + return taxRate; + } +} diff --git a/packages/server/src/services/TaxRates/GetTaxRates.ts b/packages/server/src/services/TaxRates/GetTaxRates.ts new file mode 100644 index 000000000..6344d4eed --- /dev/null +++ b/packages/server/src/services/TaxRates/GetTaxRates.ts @@ -0,0 +1,21 @@ +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; + +@Service() +export class GetTaxRatesService { + @Inject() + private tenancy: HasTenancyService; + + /** + * + * @param {number} tenantId + * @returns + */ + public async getTaxRates(tenantId: number) { + const { TaxRate } = this.tenancy.models(tenantId); + + const taxRates = await TaxRate.query(); + + return taxRates; + } +} diff --git a/packages/server/src/services/TaxRates/TaxRatesApplication.ts b/packages/server/src/services/TaxRates/TaxRatesApplication.ts new file mode 100644 index 000000000..63190c83b --- /dev/null +++ b/packages/server/src/services/TaxRates/TaxRatesApplication.ts @@ -0,0 +1,82 @@ +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'; + +@Service() +export class TaxRatesApplication { + @Inject() + private createTaxRateService: CreateTaxRate; + + @Inject() + private editTaxRateService: EditTaxRateService; + + @Inject() + private deleteTaxRateService: DeleteTaxRateService; + + @Inject() + private getTaxRateService: GetTaxRateService; + + @Inject() + private getTaxRatesService: GetTaxRatesService; + + /** + * Creates a new tax rate. + * @param {number} tenantId + * @param {ICreateTaxRateDTO} createTaxRateDTO + * @returns + */ + 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 + */ + 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 + */ + public getTaxRate(tenantId: number, taxRateId: number) { + return this.getTaxRateService.getTaxRate(tenantId, taxRateId); + } + + /** + * Retrieves the tax rates list. + * @param {number} tenantId + * @returns + */ + public getTaxRates(tenantId: number) { + return this.getTaxRatesService.getTaxRates(tenantId); + } +} diff --git a/packages/server/src/services/TaxRates/constants.ts b/packages/server/src/services/TaxRates/constants.ts new file mode 100644 index 000000000..3ceceeab4 --- /dev/null +++ b/packages/server/src/services/TaxRates/constants.ts @@ -0,0 +1,3 @@ +const ERRORS = { + TAX_RATE_NOT_FOUND: 'TAX_RATE_NOT_FOUND', +}; diff --git a/packages/server/src/subscribers/events.ts b/packages/server/src/subscribers/events.ts index 19416b65d..f8653238a 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', }, /** @@ -557,4 +557,15 @@ export default { onDeleting: 'onProjectTimeDeleting', onDeleted: 'onProjectTimeDeleted', }, + + taxRates: { + onCreating: 'onTaxRateCreating', + onCreated: 'onTaxRateCreated', + + onEditing: 'onTaxRateEditing', + onEdited: 'onTaxRateEdited', + + onDeleting: 'onTaxRateDeleting', + onDeleted: 'onTaxRateDeleted', + }, }; From d6f56568a30037ec37c371d0d8a168dbe8c81741 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 11 Aug 2023 16:00:39 +0200 Subject: [PATCH 02/39] feat: tax rates crud service --- .../src/api/controllers/TaxRates/TaxRates.ts | 155 ++++++++++++++---- packages/server/src/api/index.ts | 2 + .../20230810191606_create_tax_rates.js | 2 + packages/server/src/interfaces/TaxRate.ts | 1 + .../TaxRates/CommandTaxRatesValidators.ts | 30 +++- .../src/services/TaxRates/CreateTaxRate.ts | 14 +- .../src/services/TaxRates/DeleteTaxRate.ts | 12 +- .../src/services/TaxRates/EditTaxRate.ts | 4 +- .../src/services/TaxRates/GetTaxRate.ts | 5 +- .../src/services/TaxRates/GetTaxRates.ts | 4 +- .../services/TaxRates/TaxRatesApplication.ts | 7 +- .../server/src/services/TaxRates/constants.ts | 3 +- 12 files changed, 189 insertions(+), 50 deletions(-) diff --git a/packages/server/src/api/controllers/TaxRates/TaxRates.ts b/packages/server/src/api/controllers/TaxRates/TaxRates.ts index d9752dede..b1493ab83 100644 --- a/packages/server/src/api/controllers/TaxRates/TaxRates.ts +++ b/packages/server/src/api/controllers/TaxRates/TaxRates.ts @@ -1,14 +1,12 @@ import { Inject, Service } from 'typedi'; import { Router, Request, Response } from 'express'; -import { body, query } from 'express-validator'; -import { pick } from 'lodash'; -import { IOptionDTO, IOptionsDTO } from '@/interfaces'; +import { body, param } from 'express-validator'; import BaseController from '@/api/controllers/BaseController'; import asyncMiddleware from '@/api/middleware/asyncMiddleware'; -import { AbilitySubject, PreferencesAction } from '@/interfaces'; -import SettingsService from '@/services/Settings/SettingsService'; -import CheckPolicies from '@/api/middleware/CheckPolicies'; import { TaxRatesApplication } from '@/services/TaxRates/TaxRatesApplication'; +import { HookNextFunction } from 'mongoose'; +import { ServiceError } from '@/exceptions'; +import { ERRORS } from '@/services/TaxRates/constants'; @Service() export class TaxRatesController extends BaseController { @@ -25,84 +23,181 @@ export class TaxRatesController extends BaseController { '/', this.taxRateValidationSchema, this.validationResult, - asyncMiddleware(this.createTaxRate.bind(this)) + asyncMiddleware(this.createTaxRate.bind(this)), + this.handleServiceErrors ); router.post( - '/:tax_rate_id', - this.taxRateValidationSchema, + '/:id', + [param('id').exists().toInt(), ...this.taxRateValidationSchema], this.validationResult, - asyncMiddleware(this.editTaxRate.bind(this)) + asyncMiddleware(this.editTaxRate.bind(this)), + this.handleServiceErrors ); router.delete( - '/:tax_rate_id', - this.taxRateValidationSchema, + '/:id', + [param('id').exists().toInt()], this.validationResult, - asyncMiddleware(this.deleteTaxRate.bind(this)) + asyncMiddleware(this.deleteTaxRate.bind(this)), + this.handleServiceErrors ); router.get( - '/:tax_rate_id', - this.taxRateValidationSchema, + '/:id', + [param('id').exists().toInt()], this.validationResult, - asyncMiddleware(this.getTaxRate.bind(this)) + asyncMiddleware(this.getTaxRate.bind(this)), + this.handleServiceErrors ); router.get( '/', - this.taxRateValidationSchema, this.validationResult, - asyncMiddleware(this.getTaxRates.bind(this)) + asyncMiddleware(this.getTaxRates.bind(this)), + this.handleServiceErrors ); return router; } /** - * Save settings validation schema. + * Tax rate validation schema. */ private get taxRateValidationSchema() { return [ + body('name').exists(), + body('code').exists().isString(), body('rate').exists().isNumeric().toFloat(), - body('is_non_recoverable').exists().isBoolean().default(false), + body('is_non_recoverable').optional().isBoolean().default(false), + body('status').optional().toUpperCase().isIn(['ARCHIVED', 'ACTIVE']), ]; } /** - * + * Creates a new tax rate. * @param {Request} req - * @param {Response} res - */ public async createTaxRate(req: Request, res: Response, next) { - const taxRate = await this.taxRatesApplication.createTaxRate() + 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 taxRate = await this.taxRatesApplication.editTaxRate(); + 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) { - await this.taxRatesApplication.deleteTaxRate(); + 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) {} + 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) {} + 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); + } + } + + /** + * Handles service errors. + * @param {Error} error + * @param {Request} req + * @param {Response} res + * @param {NextFunction} next + */ + private handleServiceErrors( + error: Error, + req: Request, + res: Response, + next: HookNextFunction + ) { + 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 }], + }); + } + } + 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 index 3cdbf1098..802c275e4 100644 --- a/packages/server/src/database/migrations/20230810191606_create_tax_rates.js +++ b/packages/server/src/database/migrations/20230810191606_create_tax_rates.js @@ -2,9 +2,11 @@ exports.up = (knex) => { return knex.schema.createTable('tax_rates', (table) => { table.increments(); table.string('name'); + table.string('code'); table.decimal('rate'); table.boolean('is_non_recoverable'); table.boolean('is_compound'); + table.integer('status'); table.timestamps(); }); }; diff --git a/packages/server/src/interfaces/TaxRate.ts b/packages/server/src/interfaces/TaxRate.ts index 4514a732a..b65a03851 100644 --- a/packages/server/src/interfaces/TaxRate.ts +++ b/packages/server/src/interfaces/TaxRate.ts @@ -4,6 +4,7 @@ export interface ITaxRate {} export interface ICommonTaxRateDTO { name: string; + code: string; rate: number; IsNonRecoverable: boolean; IsCompound: boolean; diff --git a/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts b/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts index 1b25cabee..9d3c07229 100644 --- a/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts +++ b/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts @@ -1,16 +1,36 @@ import { ServiceError } from '@/exceptions'; -import TaxRate from '@/models/TaxRate'; -import { Service } from 'typedi'; +import { Inject, Service } from 'typedi'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { ITaxRate } from '@/interfaces'; +import { ERRORS } from './constants'; @Service() export class CommandTaxRatesValidators { + @Inject() + private tenancy: HasTenancyService; + /** - * - * @param {} taxRate + * Validates the tax rate existance. + * @param {TaxRate | undefined | null} taxRate */ - public validateTaxRateExistance(taxRate: TaxRate | undefined | null) { + public validateTaxRateExistance(taxRate: ITaxRate | undefined | null) { if (!taxRate) { throw new ServiceError(ERRORS.TAX_RATE_NOT_FOUND); } } + + /** + * 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); + } + } } diff --git a/packages/server/src/services/TaxRates/CreateTaxRate.ts b/packages/server/src/services/TaxRates/CreateTaxRate.ts index 1414b5654..b7bc19fc8 100644 --- a/packages/server/src/services/TaxRates/CreateTaxRate.ts +++ b/packages/server/src/services/TaxRates/CreateTaxRate.ts @@ -9,6 +9,7 @@ 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 { @@ -21,14 +22,25 @@ export class CreateTaxRate { @Inject() private uow: UnitOfWork; + @Inject() + private validators: CommandTaxRatesValidators; + /** * Creates a new tax rate. * @param {number} tenantId * @param {ICreateTaxRateDTO} createTaxRateDTO */ - public createTaxRate(tenantId: number, createTaxRateDTO: ICreateTaxRateDTO) { + 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, { diff --git a/packages/server/src/services/TaxRates/DeleteTaxRate.ts b/packages/server/src/services/TaxRates/DeleteTaxRate.ts index 17f3a339e..27c104de1 100644 --- a/packages/server/src/services/TaxRates/DeleteTaxRate.ts +++ b/packages/server/src/services/TaxRates/DeleteTaxRate.ts @@ -22,19 +22,21 @@ export class DeleteTaxRateService { private validators: CommandTaxRatesValidators; /** - * - * @param tenantId - * @param taxRateId + * 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 `onSaleInvoiceCreating` event. + // Triggers `onTaxRateDeleting` event. await this.eventPublisher.emitAsync(events.taxRates.onDeleting, { oldTaxRate, tenantId, @@ -43,7 +45,7 @@ export class DeleteTaxRateService { await TaxRate.query(trx).findById(taxRateId).delete(); - // + // Triggers `onTaxRateDeleted` event. await this.eventPublisher.emitAsync(events.taxRates.onDeleted, { oldTaxRate, tenantId, diff --git a/packages/server/src/services/TaxRates/EditTaxRate.ts b/packages/server/src/services/TaxRates/EditTaxRate.ts index a6b502388..7ae9bbfd7 100644 --- a/packages/server/src/services/TaxRates/EditTaxRate.ts +++ b/packages/server/src/services/TaxRates/EditTaxRate.ts @@ -26,10 +26,11 @@ export class EditTaxRateService { private validators: CommandTaxRatesValidators; /** - * + * Edits the given tax rate. * @param {number} tenantId * @param {number} taxRateId * @param {IEditTaxRateDTO} taxRateEditDTO + * @returns {Promise} */ public editTaxRate( tenantId: number, @@ -40,6 +41,7 @@ export class EditTaxRateService { const oldTaxRate = TaxRate.query().findById(taxRateId); + // Validates the tax rate existance. this.validators.validateTaxRateExistance(oldTaxRate); return this.uow.withTransaction(tenantId, async (trx: Knex.Transaction) => { diff --git a/packages/server/src/services/TaxRates/GetTaxRate.ts b/packages/server/src/services/TaxRates/GetTaxRate.ts index 536741b3e..5dacd3d3f 100644 --- a/packages/server/src/services/TaxRates/GetTaxRate.ts +++ b/packages/server/src/services/TaxRates/GetTaxRate.ts @@ -11,16 +11,17 @@ export class GetTaxRateService { private validators: CommandTaxRatesValidators; /** - * + * Retrieves the given tax rate. * @param {number} tenantId * @param {number} taxRateId - * @returns + * @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); return taxRate; diff --git a/packages/server/src/services/TaxRates/GetTaxRates.ts b/packages/server/src/services/TaxRates/GetTaxRates.ts index 6344d4eed..8086dd121 100644 --- a/packages/server/src/services/TaxRates/GetTaxRates.ts +++ b/packages/server/src/services/TaxRates/GetTaxRates.ts @@ -7,9 +7,9 @@ export class GetTaxRatesService { private tenancy: HasTenancyService; /** - * + * Retrieves the tax rates list. * @param {number} tenantId - * @returns + * @returns {Promise} */ public async getTaxRates(tenantId: number) { const { TaxRate } = this.tenancy.models(tenantId); diff --git a/packages/server/src/services/TaxRates/TaxRatesApplication.ts b/packages/server/src/services/TaxRates/TaxRatesApplication.ts index 63190c83b..cf141bd47 100644 --- a/packages/server/src/services/TaxRates/TaxRatesApplication.ts +++ b/packages/server/src/services/TaxRates/TaxRatesApplication.ts @@ -27,7 +27,7 @@ export class TaxRatesApplication { * Creates a new tax rate. * @param {number} tenantId * @param {ICreateTaxRateDTO} createTaxRateDTO - * @returns + * @returns {Promise} */ public createTaxRate(tenantId: number, createTaxRateDTO: ICreateTaxRateDTO) { return this.createTaxRateService.createTaxRate(tenantId, createTaxRateDTO); @@ -38,6 +38,7 @@ export class TaxRatesApplication { * @param {number} tenantId * @param {number} taxRateId * @param {IEditTaxRateDTO} taxRateEditDTO + * @returns {Promise} */ public editTaxRate( tenantId: number, @@ -65,7 +66,7 @@ export class TaxRatesApplication { * Retrieves the given tax rate. * @param {number} tenantId * @param {number} taxRateId - * @returns + * @returns {Promise} */ public getTaxRate(tenantId: number, taxRateId: number) { return this.getTaxRateService.getTaxRate(tenantId, taxRateId); @@ -74,7 +75,7 @@ export class TaxRatesApplication { /** * Retrieves the tax rates list. * @param {number} tenantId - * @returns + * @returns {Promise} */ public getTaxRates(tenantId: number) { return this.getTaxRatesService.getTaxRates(tenantId); diff --git a/packages/server/src/services/TaxRates/constants.ts b/packages/server/src/services/TaxRates/constants.ts index 3ceceeab4..71cf5e07e 100644 --- a/packages/server/src/services/TaxRates/constants.ts +++ b/packages/server/src/services/TaxRates/constants.ts @@ -1,3 +1,4 @@ -const ERRORS = { +export const ERRORS = { TAX_RATE_NOT_FOUND: 'TAX_RATE_NOT_FOUND', + TAX_CODE_NOT_UNIQUE: 'TAX_CODE_NOT_UNIQUE', }; From a7644e64811ef15cfcf11840dcbfb55568d3ead2 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 11 Aug 2023 21:08:30 +0200 Subject: [PATCH 03/39] feat: tax rates on sale invoice service --- .../api/controllers/Sales/SalesInvoices.ts | 10 +++- .../20230810191606_create_tax_rates.js | 31 ++++++---- packages/server/src/interfaces/ItemEntry.ts | 6 ++ packages/server/src/interfaces/SaleInvoice.ts | 1 + packages/server/src/loaders/eventEmitter.ts | 2 + .../TaxRates/CommandTaxRatesValidators.ts | 26 ++++++++- .../server/src/services/TaxRates/constants.ts | 1 + .../SaleEstimateTaxRateValidateSubscriber.ts | 58 +++++++++++++++++++ .../SaleInvoiceTaxRateValidateSubscriber.ts | 56 ++++++++++++++++++ .../SaleReceiptTaxRateValidateSubscriber.ts | 56 ++++++++++++++++++ 10 files changed, 235 insertions(+), 12 deletions(-) create mode 100644 packages/server/src/services/TaxRates/subscribers/SaleEstimateTaxRateValidateSubscriber.ts create mode 100644 packages/server/src/services/TaxRates/subscribers/SaleInvoiceTaxRateValidateSubscriber.ts create mode 100644 packages/server/src/services/TaxRates/subscribers/SaleReceiptTaxRateValidateSubscriber.ts diff --git a/packages/server/src/api/controllers/Sales/SalesInvoices.ts b/packages/server/src/api/controllers/Sales/SalesInvoices.ts index c9975b710..044ddc153 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_tax_exclusive').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,8 @@ export default class SaleInvoicesController extends BaseController { .optional({ nullable: true }) .trim() .escape(), + check('entries.*.tax_code').optional({ nullable: true }).trim().escape(), + check('entries.*.tax_rate').optional().isNumeric().toFloat(), check('entries.*.warehouse_id') .optional({ nullable: true }) .isNumeric() @@ -756,6 +759,11 @@ 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 }], + }); + } } next(error); } diff --git a/packages/server/src/database/migrations/20230810191606_create_tax_rates.js b/packages/server/src/database/migrations/20230810191606_create_tax_rates.js index 802c275e4..63b486d2a 100644 --- a/packages/server/src/database/migrations/20230810191606_create_tax_rates.js +++ b/packages/server/src/database/migrations/20230810191606_create_tax_rates.js @@ -1,14 +1,25 @@ exports.up = (knex) => { - return knex.schema.createTable('tax_rates', (table) => { - table.increments(); - table.string('name'); - table.string('code'); - table.decimal('rate'); - table.boolean('is_non_recoverable'); - table.boolean('is_compound'); - table.integer('status'); - table.timestamps(); - }); + return knex.schema + .createTable('tax_rates', (table) => { + table.increments(); + table.string('name'); + table.string('code'); + table.decimal('rate'); + table.boolean('is_non_recoverable'); + table.boolean('is_compound'); + table.integer('status'); + table.timestamps(); + }) + .table('items_entries', (table) => { + table.boolean(['is_tax_exclusive']); + table.string('tax_code'); + table.decimal('tax_rate'); + table.decimal('tax_amount_withheld') + }) + .table('sales_invoices', (table) => { + table.boolean(['is_tax_exclusive']); + table.decimal('tax_amount_withheld') + }); }; exports.down = (knex) => { diff --git a/packages/server/src/interfaces/ItemEntry.ts b/packages/server/src/interfaces/ItemEntry.ts index 135f52e21..4b905668b 100644 --- a/packages/server/src/interfaces/ItemEntry.ts +++ b/packages/server/src/interfaces/ItemEntry.ts @@ -32,6 +32,9 @@ export interface IItemEntry { projectRefType?: ProjectLinkRefType; projectRefInvoicedAmount?: number; + taxCode: string; + taxRate: number; + item?: IItem; allocatedCostEntries?: IBillLandedCostEntry[]; @@ -46,6 +49,9 @@ export interface IItemEntryDTO { projectRefId?: number; projectRefType?: ProjectLinkRefType; projectRefInvoicedAmount?: number; + + taxCode: string; + taxRate: number; } export enum ProjectLinkRefType { diff --git a/packages/server/src/interfaces/SaleInvoice.ts b/packages/server/src/interfaces/SaleInvoice.ts index 5dc91b062..f2283de43 100644 --- a/packages/server/src/interfaces/SaleInvoice.ts +++ b/packages/server/src/interfaces/SaleInvoice.ts @@ -44,6 +44,7 @@ export interface ISaleInvoiceDTO { exchangeRate?: number; invoiceMessage: string; termsConditions: string; + isTaxExclusive: boolean; entries: IItemEntryDTO[]; delivered: boolean; diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index 94ac8adcf..f42144cb0 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -80,6 +80,7 @@ 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'; export default () => { return new EventPublisher(); @@ -187,5 +188,6 @@ export const susbcribers = () => { ProjectBillableTasksSubscriber, ProjectBillableExpensesSubscriber, ProjectBillableBillSubscriber, + SaleInvoiceTaxRateValidateSubscriber ]; }; diff --git a/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts b/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts index 9d3c07229..dcd444e0a 100644 --- a/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts +++ b/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts @@ -1,8 +1,9 @@ import { ServiceError } from '@/exceptions'; import { Inject, Service } from 'typedi'; import HasTenancyService from '../Tenancy/TenancyService'; -import { ITaxRate } from '@/interfaces'; +import { IItemEntryDTO, ITaxRate } from '@/interfaces'; import { ERRORS } from './constants'; +import { difference } from 'lodash'; @Service() export class CommandTaxRatesValidators { @@ -33,4 +34,27 @@ export class CommandTaxRatesValidators { throw new ServiceError(ERRORS.TAX_CODE_NOT_UNIQUE); } } + + /** + * Validates the tax codes of the given item entries DTO. + * @param {number} tenantId + * @param {IItemEntryDTO[]} itemEntriesDTO + */ + 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); + + 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); + } + } } diff --git a/packages/server/src/services/TaxRates/constants.ts b/packages/server/src/services/TaxRates/constants.ts index 71cf5e07e..5822d6a5d 100644 --- a/packages/server/src/services/TaxRates/constants.ts +++ b/packages/server/src/services/TaxRates/constants.ts @@ -1,4 +1,5 @@ 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', }; diff --git a/packages/server/src/services/TaxRates/subscribers/SaleEstimateTaxRateValidateSubscriber.ts b/packages/server/src/services/TaxRates/subscribers/SaleEstimateTaxRateValidateSubscriber.ts new file mode 100644 index 000000000..e5f04e4a3 --- /dev/null +++ b/packages/server/src/services/TaxRates/subscribers/SaleEstimateTaxRateValidateSubscriber.ts @@ -0,0 +1,58 @@ +import { Inject, Service } from 'typedi'; +import { + ISaleEstimateCreatingPayload, + ISaleEstimateEditingPayload, + ISaleInvoiceCreatingPaylaod, + ISaleInvoiceEditingPayload, +} from '@/interfaces'; +import events from '@/subscribers/events'; +import { CommandTaxRatesValidators } from '../CommandTaxRatesValidators'; + +@Service() +export class SaleEstimateTaxRateValidateSubscriber { + @Inject() + private taxRateDTOValidator: CommandTaxRatesValidators; + + /** + * Attaches events with handlers. + */ + public attach(bus) { + bus.subscribe( + events.saleEstimate.onCreating, + this.validateSaleEstimateEntriesTaxCodeExistanceOnCreating + ); + bus.subscribe( + events.saleEstimate.onEditing, + this.validateSaleEstimateEntriesTaxCodeExistanceOnEditing + ); + return bus; + } + + /** + * Validate invoice entries tax rate code existance. + * @param {ISaleInvoiceCreatingPaylaod} + */ + private validateSaleEstimateEntriesTaxCodeExistanceOnCreating = async ({ + estimateDTO, + tenantId, + }: ISaleEstimateCreatingPayload) => { + await this.taxRateDTOValidator.validateItemEntriesTaxCode( + tenantId, + estimateDTO.entries + ); + }; + + /** + * + * @param {ISaleInvoiceEditingPayload} + */ + private validateSaleEstimateEntriesTaxCodeExistanceOnEditing = async ({ + tenantId, + estimateDTO, + }: ISaleEstimateEditingPayload) => { + await this.taxRateDTOValidator.validateItemEntriesTaxCode( + tenantId, + estimateDTO.entries + ); + }; +} 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..afe085e2a --- /dev/null +++ b/packages/server/src/services/TaxRates/subscribers/SaleInvoiceTaxRateValidateSubscriber.ts @@ -0,0 +1,56 @@ +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.onEditing, + this.validateSaleInvoiceEntriesTaxCodeExistanceOnEditing + ); + return bus; + } + + /** + * Validate invoice entries tax rate code existance. + * @param {ISaleInvoiceCreatingPaylaod} + */ + private validateSaleInvoiceEntriesTaxCodeExistanceOnCreating = async ({ + saleInvoiceDTO, + tenantId, + }: ISaleInvoiceCreatingPaylaod) => { + await this.taxRateDTOValidator.validateItemEntriesTaxCode( + tenantId, + saleInvoiceDTO.entries + ); + }; + + /** + * + * @param {ISaleInvoiceEditingPayload} + */ + private validateSaleInvoiceEntriesTaxCodeExistanceOnEditing = async ({ + tenantId, + saleInvoiceDTO, + }: ISaleInvoiceEditingPayload) => { + await this.taxRateDTOValidator.validateItemEntriesTaxCode( + tenantId, + saleInvoiceDTO.entries + ); + }; +} diff --git a/packages/server/src/services/TaxRates/subscribers/SaleReceiptTaxRateValidateSubscriber.ts b/packages/server/src/services/TaxRates/subscribers/SaleReceiptTaxRateValidateSubscriber.ts new file mode 100644 index 000000000..d4c8e21b9 --- /dev/null +++ b/packages/server/src/services/TaxRates/subscribers/SaleReceiptTaxRateValidateSubscriber.ts @@ -0,0 +1,56 @@ +import { Inject, Service } from 'typedi'; +import { + ISaleReceiptCreatingPayload, + ISaleReceiptEditingPayload, +} from '@/interfaces'; +import events from '@/subscribers/events'; +import { CommandTaxRatesValidators } from '../CommandTaxRatesValidators'; + +@Service() +export class SaleReceiptTaxRateValidateSubscriber { + @Inject() + private taxRateDTOValidator: CommandTaxRatesValidators; + + /** + * Attaches events with handlers. + */ + public attach(bus) { + bus.subscribe( + events.saleReceipt.onCreating, + this.validateSaleReceiptEntriesTaxCodeExistanceOnCreating + ); + bus.subscribe( + events.saleReceipt.onEditing, + this.validateSaleReceiptEntriesTaxCodeExistanceOnEditing + ); + return bus; + } + + /** + * Validate receipt entries tax rate code existance. + * @param {ISaleInvoiceCreatingPaylaod} + */ + private validateSaleReceiptEntriesTaxCodeExistanceOnCreating = async ({ + tenantId, + saleReceiptDTO, + }: ISaleReceiptCreatingPayload) => { + await this.taxRateDTOValidator.validateItemEntriesTaxCode( + tenantId, + saleReceiptDTO.entries + ); + }; + + /** + * + * @param {ISaleInvoiceEditingPayload} + */ + private validateSaleReceiptEntriesTaxCodeExistanceOnEditing = async ({ + tenantId, + saleReceiptDTO, + }: ISaleReceiptEditingPayload) => { + await this.taxRateDTOValidator.validateItemEntriesTaxCode( + tenantId, + saleReceiptDTO.entries + ); + }; +} From d1121f0b81ea1934731ac4f418d0b647bc03bd68 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 14 Aug 2023 14:59:10 +0200 Subject: [PATCH 04/39] feat(server): wip tax rate on sale invoice service --- .../20230810191606_create_tax_rates.js | 19 ++- .../src/database/seeds/data/accounts.js | 108 ++++++++------- packages/server/src/interfaces/Item.ts | 8 ++ packages/server/src/interfaces/ItemEntry.ts | 1 + .../server/src/interfaces/SaleEstimate.ts | 4 +- packages/server/src/interfaces/SaleInvoice.ts | 5 +- packages/server/src/interfaces/TaxRate.ts | 7 + packages/server/src/loaders/eventEmitter.ts | 10 +- packages/server/src/loaders/tenantModels.ts | 2 + packages/server/src/models/ItemEntry.ts | 20 ++- packages/server/src/models/SaleInvoice.ts | 126 +++++++++++++++++- .../server/src/models/TaxRateTransaction.ts | 42 ++++++ .../src/repositories/AccountRepository.ts | 26 +++- .../CommandSaleInvoiceDTOTransformer.ts | 5 + .../Sales/Invoices/InvoiceGLEntries.ts | 62 +++++++-- .../TaxRates/ItemEntriesTaxTransactions.ts | 22 +++ .../WriteTaxTransactionsItemEntries.ts | 65 +++++++++ .../WriteInvoiceTaxTransactionsSubscriber.ts | 56 ++++++++ 18 files changed, 514 insertions(+), 74 deletions(-) create mode 100644 packages/server/src/models/TaxRateTransaction.ts create mode 100644 packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts create mode 100644 packages/server/src/services/TaxRates/WriteTaxTransactionsItemEntries.ts create mode 100644 packages/server/src/services/TaxRates/subscribers/WriteInvoiceTaxTransactionsSubscriber.ts diff --git a/packages/server/src/database/migrations/20230810191606_create_tax_rates.js b/packages/server/src/database/migrations/20230810191606_create_tax_rates.js index 63b486d2a..472f6f533 100644 --- a/packages/server/src/database/migrations/20230810191606_create_tax_rates.js +++ b/packages/server/src/database/migrations/20230810191606_create_tax_rates.js @@ -11,14 +11,25 @@ exports.up = (knex) => { table.timestamps(); }) .table('items_entries', (table) => { - table.boolean(['is_tax_exclusive']); + table.boolean('is_tax_exclusive'); table.string('tax_code'); table.decimal('tax_rate'); - table.decimal('tax_amount_withheld') }) .table('sales_invoices', (table) => { - table.boolean(['is_tax_exclusive']); - table.decimal('tax_amount_withheld') + table.boolean('is_tax_exclusive'); + table.decimal('tax_amount_withheld'); + }) + .createTable('tax_rate_transactions', (table) => { + table.increments('id'); + + table.string('tax_name'); + table.string('tax_code'); + + table.string('reference_type'); + table.integer('reference_id'); + + table.decimal('tax_amount'); + table.integer('tax_account_id').unsigned(); }); }; diff --git a/packages/server/src/database/seeds/data/accounts.js b/packages/server/src/database/seeds/data/accounts.js index fb6bf6c07..fa9ee0d0c 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: 'other-current-liability', + 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 availiable for sale.', + description: + 'An account that holds valuation of products or goods that availiable 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/Item.ts b/packages/server/src/interfaces/Item.ts index 748fccefb..6e942fd81 100644 --- a/packages/server/src/interfaces/Item.ts +++ b/packages/server/src/interfaces/Item.ts @@ -54,6 +54,14 @@ export interface IItemDTO { sellDescription: string; purchaseDescription: string; + // Used as an override if the default Tax Code for the selected `costAccountId` is not correct + purchaseTaxCode: string; + purchaseTaxId: string; + + // Used as an override if the default Tax Code for the selected `sellAccountId` is not correct + saleTaxCode: string; + saleTaxId: string; + quantityOnHand: number; note: string; diff --git a/packages/server/src/interfaces/ItemEntry.ts b/packages/server/src/interfaces/ItemEntry.ts index 4b905668b..f6899777f 100644 --- a/packages/server/src/interfaces/ItemEntry.ts +++ b/packages/server/src/interfaces/ItemEntry.ts @@ -34,6 +34,7 @@ export interface IItemEntry { taxCode: string; taxRate: number; + taxAmount: number; item?: IItem; 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 f2283de43..874d1b6c9 100644 --- a/packages/server/src/interfaces/SaleInvoice.ts +++ b/packages/server/src/interfaces/SaleInvoice.ts @@ -1,5 +1,5 @@ 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'; @@ -33,6 +33,9 @@ export interface ISaleInvoice { writtenoffExpenseAccountId?: number; writtenoffExpenseAccount?: IAccount; + + taxAmountWithheld: number; + taxes: ITaxTransaction[] } export interface ISaleInvoiceDTO { diff --git a/packages/server/src/interfaces/TaxRate.ts b/packages/server/src/interfaces/TaxRate.ts index b65a03851..16a38e9bb 100644 --- a/packages/server/src/interfaces/TaxRate.ts +++ b/packages/server/src/interfaces/TaxRate.ts @@ -47,3 +47,10 @@ export interface ITaxRateDeletedPayload { tenantId: number; trx: Knex.Transaction; } + + +export interface ITaxTransaction { + taxAmount: number; + taxName: string; + taxCode: string; +} \ No newline at end of file diff --git a/packages/server/src/loaders/eventEmitter.ts b/packages/server/src/loaders/eventEmitter.ts index f42144cb0..ea2b97677 100644 --- a/packages/server/src/loaders/eventEmitter.ts +++ b/packages/server/src/loaders/eventEmitter.ts @@ -81,6 +81,9 @@ import { ProjectBillableExpensesSubscriber } from '@/services/Projects/Projects/ import { ProjectBillableBillSubscriber } from '@/services/Projects/Projects/ProjectBillableBillSubscriber'; import { SyncActualTimeTaskSubscriber } from '@/services/Projects/Times/SyncActualTimeTaskSubscriber'; import { SaleInvoiceTaxRateValidateSubscriber } from '@/services/TaxRates/subscribers/SaleInvoiceTaxRateValidateSubscriber'; +import { SaleEstimateTaxRateValidateSubscriber } from '@/services/TaxRates/subscribers/SaleEstimateTaxRateValidateSubscriber'; +import { SaleReceiptTaxRateValidateSubscriber } from '@/services/TaxRates/subscribers/SaleReceiptTaxRateValidateSubscriber'; +import { WriteInvoiceTaxTransactionsSubscriber } from '@/services/TaxRates/subscribers/WriteInvoiceTaxTransactionsSubscriber'; export default () => { return new EventPublisher(); @@ -188,6 +191,11 @@ export const susbcribers = () => { ProjectBillableTasksSubscriber, ProjectBillableExpensesSubscriber, ProjectBillableBillSubscriber, - SaleInvoiceTaxRateValidateSubscriber + + // Tax Rates + SaleInvoiceTaxRateValidateSubscriber, + SaleEstimateTaxRateValidateSubscriber, + SaleReceiptTaxRateValidateSubscriber, + WriteInvoiceTaxTransactionsSubscriber ]; }; diff --git a/packages/server/src/loaders/tenantModels.ts b/packages/server/src/loaders/tenantModels.ts index 3c7865922..d76c7e618 100644 --- a/packages/server/src/loaders/tenantModels.ts +++ b/packages/server/src/loaders/tenantModels.ts @@ -59,6 +59,7 @@ 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 = { @@ -121,6 +122,7 @@ export default (knex) => { Time, Task, TaxRate, + TaxRateTransaction, }; return mapValues(models, (model) => model.bindKnex(knex)); }; diff --git a/packages/server/src/models/ItemEntry.ts b/packages/server/src/models/ItemEntry.ts index cae1c9cf2..2d7e5de95 100644 --- a/packages/server/src/models/ItemEntry.ts +++ b/packages/server/src/models/ItemEntry.ts @@ -2,6 +2,8 @@ import { Model } from 'objection'; import TenantModel from 'models/TenantModel'; export default class ItemEntry extends TenantModel { + public taxRate: number; + /** * Table name. */ @@ -17,7 +19,7 @@ export default class ItemEntry extends TenantModel { } static get virtualAttributes() { - return ['amount']; + return ['amount', 'taxAmount']; } get amount() { @@ -31,6 +33,22 @@ export default class ItemEntry extends TenantModel { return discount ? total - total * discount * 0.01 : total; } + /** + * Tag rate fraction. + * @returns {number} + */ + get tagRateFraction() { + return this.taxRate / 100; + } + + /** + * Tax amount withheld. + * @returns {number} + */ + get taxAmount() { + return this.amount * this.tagRateFraction; + } + static get relationMappings() { const Item = require('models/Item'); const BillLandedCostEntry = require('models/BillLandedCostEntry'); diff --git a/packages/server/src/models/SaleInvoice.ts b/packages/server/src/models/SaleInvoice.ts index 38f21f285..ca2612397 100644 --- a/packages/server/src/models/SaleInvoice.ts +++ b/packages/server/src/models/SaleInvoice.ts @@ -13,6 +13,11 @@ export default class SaleInvoice extends mixin(TenantModel, [ CustomViewBaseModel, ModelSearchable, ]) { + taxAmountWithheld: number; + balance: number; + paymentAmount: number; + exchangeRate: number; + /** * Table name */ @@ -51,12 +56,115 @@ export default class SaleInvoice extends mixin(TenantModel, [ ]; } + /** + * Invoice total FCY. + * @returns {number} + */ + get totalFcy() { + return this.amountFcy + this.taxAmountWithheldFcy; + } + + /** + * Invoice total BCY. + * @returns {number} + */ + get totalBcy() { + return this.amountBcy + this.taxAmountWithheldBcy; + } + + /** + * Tax amount withheld FCY. + * @returns {number} + */ + get taxAmountWithheldFcy() { + return this.taxAmountWithheld; + } + + /** + * Tax amount withheld BCY. + * @returns {number} + */ + get taxAmountWithheldBcy() { + return this.taxAmountWithheld; + } + + /** + * Subtotal FCY. + * @returns {number} + */ + get subtotalFcy() { + return this.amountFcy; + } + + /** + * Subtotal BCY. + * @returns {number} + */ + get subtotalBcy() { + return this.amountBcy; + } + + /** + * Invoice due amount FCY. + * @returns {number} + */ + get dueAmountFcy() { + return this.amountFcy - this.paymentAmountFcy; + } + + /** + * Invoice due amount BCY. + * @returns {number} + */ + get dueAmountBcy() { + return this.amountBcy - this.paymentAmountBcy; + } + + /** + * Invoice amount FCY. + * @returns {number} + */ + get amountFcy() { + return this.balance; + } + + /** + * Invoice amount BCY. + * @returns {number} + */ + get amountBcy() { + return this.balance * this.exchangeRate; + } + + /** + * Invoice payment amount FCY. + * @returns {number} + */ + get paymentAmountFcy() { + return this.paymentAmount; + } + + /** + * Invoice payment amount BCY. + * @returns {number} + */ + get paymentAmountBcy() { + return this.paymentAmount * this.exchangeRate; + } + + /** + * + */ + get total() { + return this.balance + this.taxAmountWithheld; + } + /** * Invoice amount in local currency. * @returns {number} */ get localAmount() { - return this.balance * this.exchangeRate; + return this.total * this.exchangeRate; } /** @@ -333,6 +441,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 { /** @@ -428,6 +537,21 @@ export default class SaleInvoice extends mixin(TenantModel, [ to: 'accounts.id', }, }, + + /** + * + */ + 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/TaxRateTransaction.ts b/packages/server/src/models/TaxRateTransaction.ts new file mode 100644 index 000000000..b26c26903 --- /dev/null +++ b/packages/server/src/models/TaxRateTransaction.ts @@ -0,0 +1,42 @@ +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() { + return {}; + } +} 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/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts b/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts index c4743350e..dc728f56c 100644 --- a/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts @@ -17,6 +17,7 @@ import HasTenancyService from '@/services/Tenancy/TenancyService'; import { CommandSaleInvoiceValidators } from './CommandSaleInvoiceValidators'; import { SaleInvoiceIncrement } from './SaleInvoiceIncrement'; import { formatDateFields } from 'utils'; +import { ItemEntriesTaxTransactions } from '@/services/TaxRates/ItemEntriesTaxTransactions'; @Service() export class CommandSaleInvoiceDTOTransformer { @@ -38,6 +39,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. @@ -96,6 +100,7 @@ export class CommandSaleInvoiceDTOTransformer { } as ISaleInvoice; return R.compose( + this.taxDTOTransformer.assocTaxAmountWithheldFromEntries, this.branchDTOTransform.transformDTO(tenantId), this.warehouseDTOTransform.transformDTO(tenantId) )(initialDTO); diff --git a/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts b/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts index c47291fe0..8371aed86 100644 --- a/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts +++ b/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts @@ -1,12 +1,13 @@ import * as R from 'ramda'; +import { Knex } from 'knex'; import { ISaleInvoice, IItemEntry, ILedgerEntry, AccountNormal, ILedger, + ITaxTransaction, } from '@/interfaces'; -import { Knex } from 'knex'; import { Service, Inject } from 'typedi'; import Ledger from '@/services/Accounting/Ledger'; import LedgerStorageService from '@/services/Accounting/LedgerStorageService'; @@ -22,8 +23,8 @@ export class SaleInvoiceGLEntries { /** * 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 +43,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 +103,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 +156,7 @@ export class SaleInvoiceGLEntries { return { ...commonEntry, - debit: saleInvoice.localAmount, + debit: saleInvoice.totalBcy, accountId: ARAccountId, contactId: saleInvoice.customerId, accountNormal: AccountNormal.DEBIT, @@ -176,7 +189,27 @@ export class SaleInvoiceGLEntries { itemId: entry.itemId, itemQuantity: entry.quantity, accountNormal: AccountNormal.CREDIT, - projectId: entry.projectId || saleInvoice.projectId + projectId: entry.projectId || saleInvoice.projectId, + }; + } + ); + + /** + * Retreives the GL entry of tax payable. + * @param {ISaleInvoice} saleInvoice - + * @param {number} taxPayableAccountId - + * @returns {ILedgerEntry} + */ + private getInvoiceTaxEntry = R.curry( + (saleInvoice: ISaleInvoice, taxPayableAccountId: number): ILedgerEntry => { + const commonEntry = this.getInvoiceGLCommonEntry(saleInvoice); + + return { + ...commonEntry, + credit: saleInvoice.taxAmountWithheld, + accountId: taxPayableAccountId, + index: saleInvoice.entries.length + 3, + accountNormal: AccountNormal.CREDIT, }; } ); @@ -189,15 +222,18 @@ export class SaleInvoiceGLEntries { */ public getInvoiceGLEntries = ( saleInvoice: ISaleInvoice, - ARAccountId: number + ARAccountId: number, + taxPayableAccountId: number ): ILedgerEntry[] => { const receivableEntry = this.getInvoiceReceivableEntry( saleInvoice, ARAccountId ); const transformItemEntry = this.getInvoiceItemEntry(saleInvoice); - const creditEntries = saleInvoice.entries.map(transformItemEntry); - return [receivableEntry, ...creditEntries]; + const creditEntries = saleInvoice.entries.map(transformItemEntry); + const taxEntry = this.getInvoiceTaxEntry(saleInvoice, taxPayableAccountId); + + return [receivableEntry, ...creditEntries, taxEntry]; }; } diff --git a/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts b/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts new file mode 100644 index 000000000..9a1e72d80 --- /dev/null +++ b/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts @@ -0,0 +1,22 @@ +import { ItemEntry } from "@/models"; +import { sumBy } from "lodash"; +import { Service } from "typedi"; + + +@Service() +export class ItemEntriesTaxTransactions { + /** + * + * @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; + } +} \ No newline at end of file diff --git a/packages/server/src/services/TaxRates/WriteTaxTransactionsItemEntries.ts b/packages/server/src/services/TaxRates/WriteTaxTransactionsItemEntries.ts new file mode 100644 index 000000000..40304a0ab --- /dev/null +++ b/packages/server/src/services/TaxRates/WriteTaxTransactionsItemEntries.ts @@ -0,0 +1,65 @@ +import { sumBy, chain } from 'lodash'; +import { IItemEntry } from '@/interfaces'; +import HasTenancyService from '../Tenancy/TenancyService'; +import { Inject, Service } from 'typedi'; + +@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[] + ) { + const { TaxRateTransaction } = this.tenancy.models(tenantId); + const aggregatedEntries = this.aggregateItemEntriesByTaxCode(itemEntries); + + const taxTransactions = aggregatedEntries.map((entry) => ({ + taxName: 'TAX NAME', + taxCode: 'TAG_CODE', + referenceType: entry.referenceType, + referenceId: entry.referenceId, + taxAmount: entry.taxAmount, + })); + await TaxRateTransaction.query().upsertGraph(taxTransactions); + } + + /** + * + * @param {IItemEntry[]} itemEntries + * @returns {} + */ + private aggregateItemEntriesByTaxCode(itemEntries: IItemEntry[]) { + return chain(itemEntries.filter((item) => item.taxCode)) + .groupBy((item) => item.taxCode) + .values() + .map((group) => ({ ...group[0], amount: sumBy(group, 'amount') })) + .value(); + } + + /** + * + * @param itemEntries + */ + private aggregateItemEntriesByReferenceTypeId(itemEntries: IItemEntry) {} + + /** + * Removes the tax transactions from the given item entries. + * @param tenantId + * @param itemEntries + */ + public removeTaxTransactionsFromItemEntries( + tenantId: number, + itemEntries: IItemEntry[] + ) { + const { TaxRateTransaction } = this.tenancy.models(tenantId); + + const filteredEntries = itemEntries.filter((item) => item.taxCode); + } +} 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..75720d3af --- /dev/null +++ b/packages/server/src/services/TaxRates/subscribers/WriteInvoiceTaxTransactionsSubscriber.ts @@ -0,0 +1,56 @@ +import { Inject, Service } from 'typedi'; +import { + ISaleInvoiceCreatedPayload, + ISaleInvoiceDeletedPayload, +} 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.onDeleted, + this.removeInvoiceTaxTransactionsOnDeleted + ); + return bus; + } + + /** + * Validate receipt entries tax rate code existance. + * @param {ISaleInvoiceCreatingPaylaod} + */ + private writeInvoiceTaxTransactionsOnCreated = async ({ + tenantId, + saleInvoice, + }: ISaleInvoiceCreatedPayload) => { + await this.writeTaxTransactions.writeTaxTransactionsFromItemEntries( + tenantId, + saleInvoice.entries + ); + }; + + /** + * Removes the invoice tax transactions on invoice deleted. + * @param {ISaleInvoiceEditingPayload} + */ + private removeInvoiceTaxTransactionsOnDeleted = async ({ + tenantId, + oldSaleInvoice, + }: ISaleInvoiceDeletedPayload) => { + await this.writeTaxTransactions.removeTaxTransactionsFromItemEntries( + tenantId, + oldSaleInvoice.entries + ); + }; +} From 6535424d0f0a8ec13ba1d4d2df0337af85877bee Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Tue, 29 Aug 2023 19:12:19 +0200 Subject: [PATCH 05/39] feat(server): wip sale invoice tax rates --- .../api/controllers/Sales/SalesInvoices.ts | 5 ++ .../20230810191606_create_tax_rates.js | 15 ++++-- packages/server/src/interfaces/ItemEntry.ts | 6 ++- packages/server/src/interfaces/TaxRate.ts | 10 ++-- .../server/src/models/TaxRateTransaction.ts | 16 +++++- .../CommandSaleInvoiceDTOTransformer.ts | 2 + .../services/Sales/Invoices/GetSaleInvoice.ts | 3 +- .../SaleInvoiceTaxEntryTransformer.ts | 46 ++++++++++++++++ .../Sales/Invoices/SaleInvoiceTransformer.ts | 10 ++++ .../TaxRates/CommandTaxRatesValidators.ts | 31 +++++++++++ .../TaxRates/ItemEntriesTaxTransactions.ts | 43 ++++++++++++--- .../WriteTaxTransactionsItemEntries.ts | 53 ++++++++++--------- .../server/src/services/TaxRates/constants.ts | 1 + .../SaleInvoiceTaxRateValidateSubscriber.ts | 22 +++++++- .../WriteInvoiceTaxTransactionsSubscriber.ts | 5 +- 15 files changed, 219 insertions(+), 49 deletions(-) create mode 100644 packages/server/src/services/Sales/Invoices/SaleInvoiceTaxEntryTransformer.ts diff --git a/packages/server/src/api/controllers/Sales/SalesInvoices.ts b/packages/server/src/api/controllers/Sales/SalesInvoices.ts index 044ddc153..e55aeeb7d 100644 --- a/packages/server/src/api/controllers/Sales/SalesInvoices.ts +++ b/packages/server/src/api/controllers/Sales/SalesInvoices.ts @@ -764,6 +764,11 @@ export default class SaleInvoicesController extends BaseController { 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/database/migrations/20230810191606_create_tax_rates.js b/packages/server/src/database/migrations/20230810191606_create_tax_rates.js index 472f6f533..e5279151c 100644 --- a/packages/server/src/database/migrations/20230810191606_create_tax_rates.js +++ b/packages/server/src/database/migrations/20230810191606_create_tax_rates.js @@ -12,6 +12,11 @@ exports.up = (knex) => { }) .table('items_entries', (table) => { table.boolean('is_tax_exclusive'); + table + .integer('tax_rate_id') + .unsigned() + .references('id') + .inTable('tax_rates'); table.string('tax_code'); table.decimal('tax_rate'); }) @@ -21,13 +26,13 @@ exports.up = (knex) => { }) .createTable('tax_rate_transactions', (table) => { table.increments('id'); - - table.string('tax_name'); - table.string('tax_code'); - + table + .integer('tax_rate_id') + .unsigned() + .references('id') + .inTable('tax_rates'); table.string('reference_type'); table.integer('reference_id'); - table.decimal('tax_amount'); table.integer('tax_account_id').unsigned(); }); diff --git a/packages/server/src/interfaces/ItemEntry.ts b/packages/server/src/interfaces/ItemEntry.ts index f6899777f..bb67df9e8 100644 --- a/packages/server/src/interfaces/ItemEntry.ts +++ b/packages/server/src/interfaces/ItemEntry.ts @@ -32,6 +32,7 @@ export interface IItemEntry { projectRefType?: ProjectLinkRefType; projectRefInvoicedAmount?: number; + taxRateId: number | null; taxCode: string; taxRate: number; taxAmount: number; @@ -51,8 +52,9 @@ export interface IItemEntryDTO { projectRefType?: ProjectLinkRefType; projectRefInvoicedAmount?: number; - taxCode: string; - taxRate: number; + taxCodeId?: number; + taxCode?: string; + taxRate?: number; } export enum ProjectLinkRefType { diff --git a/packages/server/src/interfaces/TaxRate.ts b/packages/server/src/interfaces/TaxRate.ts index 16a38e9bb..074e46501 100644 --- a/packages/server/src/interfaces/TaxRate.ts +++ b/packages/server/src/interfaces/TaxRate.ts @@ -48,9 +48,11 @@ export interface ITaxRateDeletedPayload { trx: Knex.Transaction; } - export interface ITaxTransaction { + id?: number; + taxRateId: number; + referenceType: string; + referenceId: number; taxAmount: number; - taxName: string; - taxCode: string; -} \ No newline at end of file + taxAccountId: number; +} diff --git a/packages/server/src/models/TaxRateTransaction.ts b/packages/server/src/models/TaxRateTransaction.ts index b26c26903..3cbca88a0 100644 --- a/packages/server/src/models/TaxRateTransaction.ts +++ b/packages/server/src/models/TaxRateTransaction.ts @@ -37,6 +37,20 @@ export default class TaxRateTransaction extends mixin(TenantModel, [ * Relationship mapping. */ static get relationMappings() { - return {}; + 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/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts b/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts index dc728f56c..867e7c660 100644 --- a/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts @@ -75,6 +75,8 @@ export class CommandSaleInvoiceDTOTransformer { ...entry, })); const entries = await composeAsync( + // 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); diff --git a/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts b/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts index 7fb5a4407..b7eaa4440 100644 --- a/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts +++ b/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts @@ -33,7 +33,8 @@ export class GetSaleInvoice { .findById(saleInvoiceId) .withGraphFetched('entries.item') .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/SaleInvoiceTaxEntryTransformer.ts b/packages/server/src/services/Sales/Invoices/SaleInvoiceTaxEntryTransformer.ts new file mode 100644 index 000000000..b61242ca5 --- /dev/null +++ b/packages/server/src/services/Sales/Invoices/SaleInvoiceTaxEntryTransformer.ts @@ -0,0 +1,46 @@ +import { Transformer } from '@/lib/Transformer/Transformer'; + +export class SaleInvoiceTaxEntryTransformer extends Transformer { + /** + * Included attributes. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return ['name', 'taxRateCode', 'raxRate', 'taxRateId']; + }; + + /** + * 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 raxRate = (taxEntry) => { + return taxEntry.taxAmount || taxEntry.taxRate.rate; + }; + + /** + * Retrieve tax rate name. + * @param taxEntry + * @returns {string} + */ + protected name = (taxEntry) => { + return taxEntry.taxRate.name; + }; +} diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts b/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts index dfbd704fa..c40320c33 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 { /** @@ -15,6 +16,7 @@ export class SaleInvoiceTransformer extends Transformer { 'formattedPaymentAmount', 'formattedBalanceAmount', 'formattedExchangeRate', + 'taxes', ]; }; @@ -88,4 +90,12 @@ export class SaleInvoiceTransformer extends Transformer { protected formattedExchangeRate = (invoice): string => { return formatNumber(invoice.exchangeRate, { money: false }); }; + + /** + * Retrieve the taxes lines of sale invoice. + * @param {ISaleInvoice} invoice + */ + protected taxes = (invoice) => { + return this.item(invoice.taxes, new SaleInvoiceTaxEntryTransformer()); + }; } diff --git a/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts b/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts index dcd444e0a..74ea0ce14 100644 --- a/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts +++ b/packages/server/src/services/TaxRates/CommandTaxRatesValidators.ts @@ -45,9 +45,13 @@ export class CommandTaxRatesValidators { 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); @@ -57,4 +61,31 @@ export class CommandTaxRatesValidators { 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.taxCodeId); + const taxCodes = filteredTaxEntries.map((e) => e.taxCodeId); + + // Can't validate if there is no tax codes. + if (taxCodes.length === 0) return; + + const { TaxRate } = this.tenancy.models(tenantId); + const foundTaxCodes = await TaxRate.query().whereIn('id', taxCodes); + const foundCodes = foundTaxCodes.map((tax) => tax.id); + + const notFoundTaxCodes = difference(taxCodes, foundCodes); + + if (notFoundTaxCodes.length > 0) { + throw new ServiceError(ERRORS.ITEM_ENTRY_TAX_RATE_ID_NOT_FOUND); + } + } } diff --git a/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts b/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts index 9a1e72d80..a85d1c998 100644 --- a/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts +++ b/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts @@ -1,14 +1,18 @@ -import { ItemEntry } from "@/models"; -import { sumBy } from "lodash"; -import { Service } from "typedi"; - +import { Inject, Service } from 'typedi'; +import { keyBy, sumBy } from 'lodash'; +import * as R from 'ramda'; +import { ItemEntry } from '@/models'; +import HasTenancyService from '../Tenancy/TenancyService'; @Service() export class ItemEntriesTaxTransactions { + @Inject() + private tenancy: HasTenancyService; + /** - * - * @param model - * @returns + * Associates tax amount withheld to the model. + * @param model + * @returns */ public assocTaxAmountWithheldFromEntries(model: any) { const entries = model.entries.map((entry) => ItemEntry.fromJson(entry)); @@ -19,4 +23,27 @@ export class ItemEntriesTaxTransactions { } return model; } -} \ No newline at end of file + + /** + * 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; + }); + }; +} diff --git a/packages/server/src/services/TaxRates/WriteTaxTransactionsItemEntries.ts b/packages/server/src/services/TaxRates/WriteTaxTransactionsItemEntries.ts index 40304a0ab..d5c654c4d 100644 --- a/packages/server/src/services/TaxRates/WriteTaxTransactionsItemEntries.ts +++ b/packages/server/src/services/TaxRates/WriteTaxTransactionsItemEntries.ts @@ -1,5 +1,5 @@ -import { sumBy, chain } from 'lodash'; -import { IItemEntry } from '@/interfaces'; +import { sumBy, chain, keyBy } from 'lodash'; +import { IItemEntry, ITaxTransaction } from '@/interfaces'; import HasTenancyService from '../Tenancy/TenancyService'; import { Inject, Service } from 'typedi'; @@ -17,49 +17,54 @@ export class WriteTaxTransactionsItemEntries { tenantId: number, itemEntries: IItemEntry[] ) { - const { TaxRateTransaction } = this.tenancy.models(tenantId); + const { TaxRateTransaction, TaxRate } = this.tenancy.models(tenantId); const aggregatedEntries = this.aggregateItemEntriesByTaxCode(itemEntries); + const entriesTaxRateIds = aggregatedEntries.map((entry) => entry.taxRateId); + + const taxRates = await TaxRate.query().whereIn('id', entriesTaxRateIds); + const taxRatesById = keyBy(taxRates, 'id'); + const taxTransactions = aggregatedEntries.map((entry) => ({ - taxName: 'TAX NAME', - taxCode: 'TAG_CODE', + taxRateId: entry.taxRateId, referenceType: entry.referenceType, referenceId: entry.referenceId, - taxAmount: entry.taxAmount, - })); + taxAmount: entry.taxRate || taxRatesById[entry.taxRateId]?.rate, + })) as ITaxTransaction[]; + await TaxRateTransaction.query().upsertGraph(taxTransactions); } /** - * + * Aggregates by tax code id and sums the amount. * @param {IItemEntry[]} itemEntries - * @returns {} + * @returns {IItemEntry[]} */ - private aggregateItemEntriesByTaxCode(itemEntries: IItemEntry[]) { - return chain(itemEntries.filter((item) => item.taxCode)) - .groupBy((item) => item.taxCode) + 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(); - } - - /** - * - * @param itemEntries - */ - private aggregateItemEntriesByReferenceTypeId(itemEntries: IItemEntry) {} + }; /** * Removes the tax transactions from the given item entries. - * @param tenantId - * @param itemEntries + * @param {number} tenantId - Tenant id. + * @param {string} referenceType - Reference type. + * @param {number} referenceId - Reference id. */ - public removeTaxTransactionsFromItemEntries( + public async removeTaxTransactionsFromItemEntries( tenantId: number, - itemEntries: IItemEntry[] + referenceId: number, + referenceType: string ) { const { TaxRateTransaction } = this.tenancy.models(tenantId); - const filteredEntries = itemEntries.filter((item) => item.taxCode); + await TaxRateTransaction.query() + .where({ referenceType, referenceId }) + .delete(); } } diff --git a/packages/server/src/services/TaxRates/constants.ts b/packages/server/src/services/TaxRates/constants.ts index 5822d6a5d..a38968d93 100644 --- a/packages/server/src/services/TaxRates/constants.ts +++ b/packages/server/src/services/TaxRates/constants.ts @@ -2,4 +2,5 @@ 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', }; diff --git a/packages/server/src/services/TaxRates/subscribers/SaleInvoiceTaxRateValidateSubscriber.ts b/packages/server/src/services/TaxRates/subscribers/SaleInvoiceTaxRateValidateSubscriber.ts index afe085e2a..ab8de770a 100644 --- a/packages/server/src/services/TaxRates/subscribers/SaleInvoiceTaxRateValidateSubscriber.ts +++ b/packages/server/src/services/TaxRates/subscribers/SaleInvoiceTaxRateValidateSubscriber.ts @@ -19,6 +19,10 @@ export class SaleInvoiceTaxRateValidateSubscriber { events.saleInvoice.onCreating, this.validateSaleInvoiceEntriesTaxCodeExistanceOnCreating ); + bus.subscribe( + events.saleInvoice.onCreating, + this.validateSaleInvoiceEntriesTaxIdExistanceOnCreating + ); bus.subscribe( events.saleInvoice.onEditing, this.validateSaleInvoiceEntriesTaxCodeExistanceOnEditing @@ -27,7 +31,7 @@ export class SaleInvoiceTaxRateValidateSubscriber { } /** - * Validate invoice entries tax rate code existance. + * Validate invoice entries tax rate code existance when creating. * @param {ISaleInvoiceCreatingPaylaod} */ private validateSaleInvoiceEntriesTaxCodeExistanceOnCreating = async ({ @@ -41,7 +45,21 @@ export class SaleInvoiceTaxRateValidateSubscriber { }; /** - * + * 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 ({ diff --git a/packages/server/src/services/TaxRates/subscribers/WriteInvoiceTaxTransactionsSubscriber.ts b/packages/server/src/services/TaxRates/subscribers/WriteInvoiceTaxTransactionsSubscriber.ts index 75720d3af..eb0e1c9b3 100644 --- a/packages/server/src/services/TaxRates/subscribers/WriteInvoiceTaxTransactionsSubscriber.ts +++ b/packages/server/src/services/TaxRates/subscribers/WriteInvoiceTaxTransactionsSubscriber.ts @@ -20,7 +20,7 @@ export class WriteInvoiceTaxTransactionsSubscriber { this.writeInvoiceTaxTransactionsOnCreated ); bus.subscribe( - events.saleInvoice.onDeleted, + events.saleInvoice.onDelete, this.removeInvoiceTaxTransactionsOnDeleted ); return bus; @@ -50,7 +50,8 @@ export class WriteInvoiceTaxTransactionsSubscriber { }: ISaleInvoiceDeletedPayload) => { await this.writeTaxTransactions.removeTaxTransactionsFromItemEntries( tenantId, - oldSaleInvoice.entries + oldSaleInvoice.id, + 'SaleInvoice' ); }; } From 6baec8dd96e370a69c896b35aac483e62fdc753a Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 31 Aug 2023 02:19:18 +0200 Subject: [PATCH 06/39] feat(server): wip sales tax liability summary report --- .../api/controllers/FinancialStatements.ts | 25 +-- .../SalesTaxLiabilitySummary/index.ts | 90 +++++++++++ .../20230810191606_create_tax_rates.js | 11 +- .../src/interfaces/FinancialStatements.ts | 1 + packages/server/src/interfaces/Ledger.ts | 1 + .../interfaces/SalesTaxLiabilitySummary.ts | 33 ++++ packages/server/src/interfaces/TaxRate.ts | 6 +- .../SalesTaxLiabilitySummary.ts | 95 +++++++++++ .../SalesTaxLiabilitySummaryRepository.ts | 71 ++++++++ .../SalesTaxLiabilitySummaryService.ts | 64 ++++++++ .../SalesTaxLiabilitySummaryTable.ts | 151 ++++++++++++++++++ 11 files changed, 535 insertions(+), 13 deletions(-) create mode 100644 packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts create mode 100644 packages/server/src/interfaces/SalesTaxLiabilitySummary.ts create mode 100644 packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts create mode 100644 packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryRepository.ts create mode 100644 packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts create mode 100644 packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.ts 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..9af858699 --- /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 HasTenancyService from '@/services/Tenancy/TenancyService'; +import { SalesTaxLiabilitySummaryService } from '@/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService'; + +export default class SalesTaxLiabilitySummary extends BaseFinancialReportController { + @Inject() + private tenancy: HasTenancyService; + + @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(), + ]; + } + + /* + * + * @param {Request} req - + * @param {Response} res - + * @param {NextFunction} next - + */ + async salesTaxLiabilitySummary( + req: Request, + res: Response, + next: NextFunction + ) { + const { tenantId, settings } = 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({ + data: salesTaxLiabilityTable + }); + case 'json': + default: + const salesTaxLiability = + await this.salesTaxLiabilitySummaryService.salesTaxLiability( + tenantId, + filter + ); + return res.status(200).send({ + data: salesTaxLiability, + }); + } + } catch (error) { + next(error); + } + } +} diff --git a/packages/server/src/database/migrations/20230810191606_create_tax_rates.js b/packages/server/src/database/migrations/20230810191606_create_tax_rates.js index e5279151c..d83efd765 100644 --- a/packages/server/src/database/migrations/20230810191606_create_tax_rates.js +++ b/packages/server/src/database/migrations/20230810191606_create_tax_rates.js @@ -11,7 +11,7 @@ exports.up = (knex) => { table.timestamps(); }) .table('items_entries', (table) => { - table.boolean('is_tax_exclusive'); + table.boolean('is_inclusive_tax').defaultTo(false); table .integer('tax_rate_id') .unsigned() @@ -21,7 +21,7 @@ exports.up = (knex) => { table.decimal('tax_rate'); }) .table('sales_invoices', (table) => { - table.boolean('is_tax_exclusive'); + table.boolean('is_inclusive_tax').defaultTo(false); table.decimal('tax_amount_withheld'); }) .createTable('tax_rate_transactions', (table) => { @@ -35,6 +35,13 @@ exports.up = (knex) => { table.integer('reference_id'); table.decimal('tax_amount'); table.integer('tax_account_id').unsigned(); + }) + .table('accounts_transactions', (table) => { + table + .integer('tax_rate_id') + .unsigned() + .references('id') + .inTable('tax_rates'); }); }; 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/Ledger.ts b/packages/server/src/interfaces/Ledger.ts index 8af6ac8b8..57a17f6c5 100644 --- a/packages/server/src/interfaces/Ledger.ts +++ b/packages/server/src/interfaces/Ledger.ts @@ -47,6 +47,7 @@ export interface ILedgerEntry { itemId?: number; branchId?: number; projectId?: number; + taxRateId?: number; entryId?: number; createdAt?: Date; diff --git a/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts b/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts new file mode 100644 index 000000000..cb8e0da33 --- /dev/null +++ b/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts @@ -0,0 +1,33 @@ +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; +} + +export interface SalesTaxLiabilitySummaryRate { + taxName: string; + taxCode: string; + taxableAmount: SalesTaxLiabilitySummaryAmount; + taxAmount: SalesTaxLiabilitySummaryAmount; +} + +export enum SalesTaxLiabilitySummaryTableRowType { + TaxRate = 'TaxRate', + Total = 'Total', +} + +export interface SalesTaxLiabilitySummaryReportData { + taxRates: SalesTaxLiabilitySummaryRate[]; + total: SalesTaxLiabilitySummaryTotal; +} diff --git a/packages/server/src/interfaces/TaxRate.ts b/packages/server/src/interfaces/TaxRate.ts index 074e46501..0e6481d8d 100644 --- a/packages/server/src/interfaces/TaxRate.ts +++ b/packages/server/src/interfaces/TaxRate.ts @@ -1,6 +1,10 @@ import { Knex } from 'knex'; -export interface ITaxRate {} +export interface ITaxRate { + name: string; + code: string; + rate: number; +} export interface ICommonTaxRateDTO { name: string; 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..16e605de2 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts @@ -0,0 +1,95 @@ +import { ITaxRate } from '@/interfaces'; +import { + SalesTaxLiabilitySummaryQuery, + SalesTaxLiabilitySummaryRate, + SalesTaxLiabilitySummaryReportData, + SalesTaxLiabilitySummaryTotal, +} from '@/interfaces/SalesTaxLiabilitySummary'; +import { sumBy } from 'lodash'; +import FinancialSheet from '../FinancialSheet'; + +export class SalesTaxLiabilitySummary extends FinancialSheet { + query: SalesTaxLiabilitySummaryQuery; + taxRates: ITaxRate[]; + payableTaxesById: any; + salesTaxesById: any; + + /** + * Sales tax liability summary constructor. + * @param {SalesTaxLiabilitySummaryQuery} query + * @param {ITaxRate[]} taxRates + * @param payableTaxesById + * @param salesTaxesById + */ + constructor( + query: SalesTaxLiabilitySummaryQuery, + taxRates: ITaxRate[], + payableTaxesById: Record< + string, + { taxRateId: number; credit: number; debit: number } + >, + salesTaxesById: Record< + string, + { taxRateId: number; credit: number; debit: number } + > + ) { + 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 => { + return { + taxName: taxRate.name, + taxCode: taxRate.code, + taxableAmount: this.getAmountMeta(0), + taxAmount: this.getAmountMeta(0), + }; + }; + + /** + * Retrieves the tax rates liability nodes. + * @returns {SalesTaxLiabilitySummaryRate[]} + */ + private taxRatesLiability = (): SalesTaxLiabilitySummaryRate[] => { + return this.taxRates.map(this.taxRateLiability); + }; + + /** + * Retrieves the tax rates total node. + * @param {SalesTaxLiabilitySummaryRate[]} nodes + * @returns {SalesTaxLiabilitySummaryTotal} + */ + private taxRatesTotal = ( + nodes: SalesTaxLiabilitySummaryRate[] + ): SalesTaxLiabilitySummaryTotal => { + const taxableAmount = sumBy(nodes, 'taxableAmount.total'); + const taxAmount = sumBy(nodes, 'taxAmount.total'); + + return { + taxableAmount: this.getTotalAmountMeta(taxableAmount), + taxAmount: this.getTotalAmountMeta(taxAmount), + }; + }; + + /** + * 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..bac9b548a --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryRepository.ts @@ -0,0 +1,71 @@ +import { ACCOUNT_TYPE } from '@/data/AccountTypes'; +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 tenantId + * @returns + */ + 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 + */ + public async taxesPayableSumGroupedByRateId( + tenantId: number + ): Promise< + Record + > { + const { AccountTransaction } = this.tenancy.models(tenantId); + const { accountRepository } = this.tenancy.repositories(tenantId); + + const receivableAccount = + await accountRepository.findOrCreateAccountReceivable(); + + const groupedTaxesById = await AccountTransaction.query() + .where('account_id', receivableAccount.id) + .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 + */ + public taxesSalesSumGroupedByRateId = async (tenantId: number) => { + 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) + .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..8bd9774e6 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts @@ -0,0 +1,64 @@ +import { Inject, Service } from 'typedi'; +import { SalesTaxLiabilitySummaryRepository } from './SalesTaxLiabilitySummaryRepository'; +import { SalesTaxLiabilitySummaryQuery } from '@/interfaces/SalesTaxLiabilitySummary'; +import { SalesTaxLiabilitySummary } from './SalesTaxLiabilitySummary'; +import { SalesTaxLiabilitySummaryTable } from './SalesTaxLiabilitySummaryTable'; + +@Service() +export class SalesTaxLiabilitySummaryService { + @Inject() + private repostiory: SalesTaxLiabilitySummaryRepository; + + /** + * + * @param tenantId + * @param 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: {}, + }; + } + + /** + * + * @param tenantId + * @param query + * @returns + */ + public async salesTaxLiabilitySummaryTable( + tenantId: number, + query: SalesTaxLiabilitySummaryQuery + ) { + const report = await this.salesTaxLiability(tenantId, query); + + const table = new SalesTaxLiabilitySummaryTable(report.data, query); + + return { + table: { + rows: table.tableRows(), + columns: table.tableColumns(), + }, + }; + } +} 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..e469cbdb4 --- /dev/null +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.ts @@ -0,0 +1,151 @@ +import * as R from 'ramda'; +import { + SalesTaxLiabilitySummaryQuery, + SalesTaxLiabilitySummaryRate, + SalesTaxLiabilitySummaryReportData, + SalesTaxLiabilitySummaryTotal, +} from '@/interfaces/SalesTaxLiabilitySummary'; +import { tableRowMapper } from '@/utils'; +import { ITableColumn, ITableColumnAccessor, ITableRow } from '@/interfaces'; + +enum IROW_TYPE { + TaxRate = 'TaxRate', + Total = 'Total', +} + +export class SalesTaxLiabilitySummaryTable { + data: SalesTaxLiabilitySummaryReportData; + query: SalesTaxLiabilitySummaryQuery; + + /** + * Sales tax liability summary table constructor. + * @param {SalesTaxLiabilitySummaryReportData} data + * @param {SalesTaxLiabilitySummaryQuery} query + */ + constructor( + data: SalesTaxLiabilitySummaryReportData, + query: SalesTaxLiabilitySummaryQuery + ) { + this.data = data; + this.query = query; + } + + /** + * Retrieve the tax rate row accessors. + * @returns {ITableColumnAccessor[]} + */ + private get taxRateRowAccessor() { + return [ + { key: 'taxName', value: 'taxName' }, + { key: 'taxCode', accessor: 'taxCode' }, + { key: 'taxableAmount', accessor: 'taxableAmount.formattedAmount' }, + { key: 'taxAmount', accessor: 'taxAmount.formattedAmount' }, + ]; + } + + /** + * Retrieve the tax rate total row accessors. + * @returns {ITableColumnAccessor[]} + */ + private get taxRateTotalRowAccessors() { + return [ + { key: 'taxName', value: '' }, + { key: 'taxCode', accessor: 'taxCode' }, + { key: 'taxableAmount', accessor: 'taxableAmount.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); + } + + /** + * Retrieve the table rows. + * @returns {ITableRow[]} + */ + public tableRows(): ITableRow[] { + return R.compose( + R.concat(this.taxRatesRows), + R.prepend(this.taxRateTotalRow) + )([]); + } + + /** + * Retrieve the table columns. + * @returns {ITableColumn[]} + */ + public tableColumns(): ITableColumn[] { + return [ + { + label: 'Tax Name', + key: 'taxName', + }, + { + label: 'Tax Code', + key: 'taxCode', + }, + { + label: 'Taxable Amount', + key: 'taxableAmount', + }, + { + label: 'Tax Rate', + key: 'taxRate', + }, + ]; + } +} From 5bb95eeb1afa2a1fc289a0475687cd0ce6a242d0 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 31 Aug 2023 21:39:59 +0200 Subject: [PATCH 07/39] feat: wip sales tax liability summary report --- .../SalesTaxLiabilitySummary/index.ts | 2 +- .../interfaces/SalesTaxLiabilitySummary.ts | 10 +++++ packages/server/src/interfaces/TaxRate.ts | 1 + .../SalesTaxLiabilitySummary.ts | 40 ++++++++++--------- .../SalesTaxLiabilitySummaryRepository.ts | 26 +++++++----- .../SalesTaxLiabilitySummaryService.ts | 13 +++--- 6 files changed, 56 insertions(+), 36 deletions(-) diff --git a/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts b/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts index 9af858699..a9399c1dd 100644 --- a/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts +++ b/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts @@ -70,7 +70,7 @@ export default class SalesTaxLiabilitySummary extends BaseFinancialReportControl ); return res.status(200).send({ - data: salesTaxLiabilityTable + table: salesTaxLiabilityTable.table, }); case 'json': default: diff --git a/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts b/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts index cb8e0da33..301cdd76c 100644 --- a/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts +++ b/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts @@ -31,3 +31,13 @@ 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 } +>; diff --git a/packages/server/src/interfaces/TaxRate.ts b/packages/server/src/interfaces/TaxRate.ts index 0e6481d8d..4e1275392 100644 --- a/packages/server/src/interfaces/TaxRate.ts +++ b/packages/server/src/interfaces/TaxRate.ts @@ -1,6 +1,7 @@ import { Knex } from 'knex'; export interface ITaxRate { + id?: number; name: string; code: string; rate: number; diff --git a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts index 16e605de2..3c658dd8a 100644 --- a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts @@ -1,37 +1,33 @@ import { ITaxRate } from '@/interfaces'; import { + SalesTaxLiabilitySummaryPayableById, SalesTaxLiabilitySummaryQuery, SalesTaxLiabilitySummaryRate, SalesTaxLiabilitySummaryReportData, + SalesTaxLiabilitySummarySalesById, SalesTaxLiabilitySummaryTotal, } from '@/interfaces/SalesTaxLiabilitySummary'; import { sumBy } from 'lodash'; import FinancialSheet from '../FinancialSheet'; export class SalesTaxLiabilitySummary extends FinancialSheet { - query: SalesTaxLiabilitySummaryQuery; - taxRates: ITaxRate[]; - payableTaxesById: any; - salesTaxesById: any; + private query: SalesTaxLiabilitySummaryQuery; + private taxRates: ITaxRate[]; + private payableTaxesById: SalesTaxLiabilitySummaryPayableById; + private salesTaxesById: SalesTaxLiabilitySummarySalesById; /** * Sales tax liability summary constructor. * @param {SalesTaxLiabilitySummaryQuery} query * @param {ITaxRate[]} taxRates - * @param payableTaxesById - * @param salesTaxesById + * @param {SalesTaxLiabilitySummaryPayableById} payableTaxesById + * @param {SalesTaxLiabilitySummarySalesById} salesTaxesById */ constructor( query: SalesTaxLiabilitySummaryQuery, taxRates: ITaxRate[], - payableTaxesById: Record< - string, - { taxRateId: number; credit: number; debit: number } - >, - salesTaxesById: Record< - string, - { taxRateId: number; credit: number; debit: number } - > + payableTaxesById: SalesTaxLiabilitySummaryPayableById, + salesTaxesById: SalesTaxLiabilitySummarySalesById ) { super(); @@ -49,11 +45,19 @@ export class SalesTaxLiabilitySummary extends FinancialSheet { 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; + return { taxName: taxRate.name, taxCode: taxRate.code, - taxableAmount: this.getAmountMeta(0), - taxAmount: this.getAmountMeta(0), + taxableAmount: this.getAmountMeta(salesTaxAmount), + taxAmount: this.getAmountMeta(payableTaxAmount), }; }; @@ -73,8 +77,8 @@ export class SalesTaxLiabilitySummary extends FinancialSheet { private taxRatesTotal = ( nodes: SalesTaxLiabilitySummaryRate[] ): SalesTaxLiabilitySummaryTotal => { - const taxableAmount = sumBy(nodes, 'taxableAmount.total'); - const taxAmount = sumBy(nodes, 'taxAmount.total'); + const taxableAmount = sumBy(nodes, 'taxableAmount.amount'); + const taxAmount = sumBy(nodes, 'taxAmount.amount'); return { taxableAmount: this.getTotalAmountMeta(taxableAmount), diff --git a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryRepository.ts b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryRepository.ts index bac9b548a..c5faaec9f 100644 --- a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryRepository.ts +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryRepository.ts @@ -1,4 +1,8 @@ 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'; @@ -10,8 +14,8 @@ export class SalesTaxLiabilitySummaryRepository { /** * Retrieve tax rates. - * @param tenantId - * @returns + * @param {number} tenantId + * @returns {Promise} */ public taxRates = (tenantId: number) => { const { TaxRate } = this.tenancy.models(tenantId); @@ -22,21 +26,19 @@ export class SalesTaxLiabilitySummaryRepository { /** * Retrieve taxes payable sum grouped by tax rate id. * @param {number} tenantId - * @returns + * @returns {Promise} */ public async taxesPayableSumGroupedByRateId( tenantId: number - ): Promise< - Record - > { + ): Promise { const { AccountTransaction } = this.tenancy.models(tenantId); const { accountRepository } = this.tenancy.repositories(tenantId); - const receivableAccount = - await accountRepository.findOrCreateAccountReceivable(); + // Finds or creates tax payable account. + const payableTaxAccount = await accountRepository.findOrCreateTaxPayable(); const groupedTaxesById = await AccountTransaction.query() - .where('account_id', receivableAccount.id) + .where('account_id', payableTaxAccount.id) .groupBy('tax_rate_id') .select(['tax_rate_id']) .sum('credit as credit') @@ -48,9 +50,11 @@ export class SalesTaxLiabilitySummaryRepository { /** * Retrieve taxes sales sum grouped by tax rate id. * @param {number} tenantId - * @returns + * @returns {Promise} */ - public taxesSalesSumGroupedByRateId = async (tenantId: number) => { + public taxesSalesSumGroupedByRateId = async ( + tenantId: number + ): Promise => { const { AccountTransaction, Account } = this.tenancy.models(tenantId); const incomeAccounts = await Account.query().whereIn('accountType', [ diff --git a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts index 8bd9774e6..2eae55b49 100644 --- a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts @@ -10,9 +10,9 @@ export class SalesTaxLiabilitySummaryService { private repostiory: SalesTaxLiabilitySummaryRepository; /** - * - * @param tenantId - * @param query + * Retrieve sales tax liability summary. + * @param {number} tenantId + * @param {SalesTaxLiabilitySummaryQuery} query * @returns */ public async salesTaxLiability( @@ -41,9 +41,9 @@ export class SalesTaxLiabilitySummaryService { } /** - * - * @param tenantId - * @param query + * Retrieve sales tax liability summary table. + * @param {number} tenantId + * @param {SalesTaxLiabilitySummaryQuery} query * @returns */ public async salesTaxLiabilitySummaryTable( @@ -52,6 +52,7 @@ export class SalesTaxLiabilitySummaryService { ) { const report = await this.salesTaxLiability(tenantId, query); + // Creates the sales tax liability summary table. const table = new SalesTaxLiabilitySummaryTable(report.data, query); return { From 54dcde657f40e5d9e28ed89de124afeed85f3247 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 1 Sep 2023 01:39:16 +0200 Subject: [PATCH 08/39] feat(webapp): wip sales tax liability summary report --- .../webapp/src/constants/abilityOption.tsx | 1 + .../src/constants/financialReportsMenu.tsx | 7 + .../SalesTaxLiabilitySummary.tsx | 72 ++++++++++ .../SalesTaxLiabilitySummaryActionsBar.tsx | 133 ++++++++++++++++++ .../SalesTaxLiabilitySummaryBody.tsx | 37 +++++ .../SalesTaxLiabilitySummaryBoot.tsx | 45 ++++++ .../SalesTaxLiabilitySummaryHeader.tsx | 116 +++++++++++++++ .../SalesTaxLiabilitySummaryTable.tsx | 93 ++++++++++++ .../SalesTaxLiabilitySummary/components.tsx | 17 +++ .../dynamicColumns.ts | 55 ++++++++ .../SalesTaxLiabilitySummary/utils.ts | 89 ++++++++++++ .../withSalesTaxLiabilitySummary.ts | 15 ++ .../withSalesTaxLiabilitySummaryActions.ts | 10 ++ .../src/hooks/query/financialReports.tsx | 22 +++ packages/webapp/src/routes/dashboard.tsx | 18 ++- .../financialStatements.selectors.tsx | 12 ++ 16 files changed, 741 insertions(+), 1 deletion(-) create mode 100644 packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.tsx create mode 100644 packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryActionsBar.tsx create mode 100644 packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryBody.tsx create mode 100644 packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryBoot.tsx create mode 100644 packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryHeader.tsx create mode 100644 packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.tsx create mode 100644 packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/components.tsx create mode 100644 packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/dynamicColumns.ts create mode 100644 packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/utils.ts create mode 100644 packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummary.ts create mode 100644 packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummaryActions.ts diff --git a/packages/webapp/src/constants/abilityOption.tsx b/packages/webapp/src/constants/abilityOption.tsx index fa460ff6b..85168e352 100644 --- a/packages/webapp/src/constants/abilityOption.tsx +++ b/packages/webapp/src/constants/abilityOption.tsx @@ -169,6 +169,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 = { diff --git a/packages/webapp/src/constants/financialReportsMenu.tsx b/packages/webapp/src/constants/financialReportsMenu.tsx index e7b300bd2..8f749728f 100644 --- a/packages/webapp/src/constants/financialReportsMenu.tsx +++ b/packages/webapp/src/constants/financialReportsMenu.tsx @@ -85,6 +85,13 @@ export const financialReportMenus = [ subject: AbilitySubject.Report, ability: ReportsAction.READ_PROFIT_LOSS, }, + { + 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/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.tsx new file mode 100644 index 000000000..5a187c1da --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.tsx @@ -0,0 +1,72 @@ +// @ts-nocheck +import React, { useEffect } from 'react'; +import moment from 'moment'; + +import { SalesTaxLiabilitySummaryLoadingBar } from './components'; +import { FinancialStatement, DashboardPageContent } from '@/components'; + +import SalesTaxLiabilitySummaryHeader from './SalesTaxLiabilitySummaryHeader'; +import SalesTaxLiabilitySummaryActionsBar from './SalesTaxLiabilitySummaryActionsBar'; +import { SalesTaxLiabilitySummaryBoot } from './SalesTaxLiabilitySummaryBoot'; +import { SalesTaxLiabilitySummaryBody } from './SalesTaxLiabilitySummaryBody'; +import { useSalesTaxLiabilitySummaryQuery } from './utils'; +import withSalesTaxLiabilitySummaryActions from './withSalesTaxLiabilitySummaryActions'; +import { compose } from '@/utils'; + +/** + * Sales tax liability summary. + * @returns {React.JSX} + */ +function SalesTaxLiabilitySummary({ + // #withSalesTaxLiabilitySummaryActions + toggleSalesTaxLiabilitySummaryFilterDrawer, +}) { + const [query, setQuery] = useSalesTaxLiabilitySummaryQuery(); + + const handleFilterSubmit = (filter) => { + const newFilter = { + ...filter, + fromDate: moment(filter.fromDate).format('YYYY-MM-DD'), + toDate: moment(filter.toDate).format('YYYY-MM-DD'), + }; + setQuery({ ...newFilter }); + }; + // Handle number format submit. + const handleNumberFormatSubmit = (values) => { + setQuery({ + ...query, + numberFormat: values, + }); + }; + // Hides the filter drawer once the page unmount. + useEffect( + () => () => { + toggleSalesTaxLiabilitySummaryFilterDrawer(false); + }, + [toggleSalesTaxLiabilitySummaryFilterDrawer], + ); + + return ( + + + + + + + + + + + + ); +} + +export default compose(withSalesTaxLiabilitySummaryActions)( + SalesTaxLiabilitySummary, +); diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryActionsBar.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryActionsBar.tsx new file mode 100644 index 000000000..2fc716be0 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryActionsBar.tsx @@ -0,0 +1,133 @@ +// @ts-nocheck +import React from 'react'; +import { + NavbarGroup, + Button, + Classes, + NavbarDivider, + Popover, + PopoverInteractionKind, + Position, +} from '@blueprintjs/core'; +import classNames from 'classnames'; +import { DashboardActionsBar, FormattedMessage as T, Icon } from '@/components'; + +import NumberFormatDropdown from '@/components/NumberFormatDropdown'; + +import { compose, saveInvoke } from '@/utils'; +import { useSalesTaxLiabilitySummaryContext } from './SalesTaxLiabilitySummaryBoot'; +import withSalesTaxLiabilitySummary from './withSalesTaxLiabilitySummary'; +import withSalesTaxLiabilitySummaryActions from './withSalesTaxLiabilitySummaryActions'; + +/** + * Sales tax liability summary - actions bar. + */ +function SalesTaxLiabilitySummaryActionsBar({ + // #withSalesTaxLiabilitySummary + salesTaxLiabilitySummaryFilter, + + // #withSalesTaxLiabilitySummaryActions + toggleBalanceSheetFilterDrawer: toggleFilterDrawer, + + // #ownProps + numberFormat, + onNumberFormatSubmit, +}) { + const { isLoading, refetchBalanceSheet } = + useSalesTaxLiabilitySummaryContext(); + + // Handle filter toggle click. + const handleFilterToggleClick = () => { + toggleFilterDrawer(); + }; + + // Handle recalculate the report button. + const handleRecalcReport = () => { + refetchBalanceSheet(); + }; + + // Handle number format form submit. + const handleNumberFormatSubmit = (values) => { + saveInvoke(onNumberFormatSubmit, values); + }; + + return ( + + + + + + + + + ); +} + +export default compose( + withSalesTaxLiabilitySummary(({ salesTaxLiabilitySummaryFilter }) => ({ + salesTaxLiabilitySummaryFilter, + })), + withSalesTaxLiabilitySummaryActions, +)(SalesTaxLiabilitySummaryHeader); + +const BalanceSheetFinancialHeader = styled(FinancialStatementHeader)` + .bp3-drawer { + max-height: 520px; + } +`; diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.tsx new file mode 100644 index 000000000..60b102354 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.tsx @@ -0,0 +1,93 @@ +// @ts-nocheck +import React from 'react'; +import styled from 'styled-components'; +import intl from 'react-intl-universal'; + +import { TableStyle } from '@/constants'; +import { ReportDataTable, FinancialSheet } from '@/components'; +import { defaultExpanderReducer, tableRowTypesToClassnames } from '@/utils'; +import { useSalesTaxLiabilitySummaryContext } from './SalesTaxLiabilitySummaryBoot'; +import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization'; +import { useSalesTaxLiabilitySummaryColumns } from './utils'; +import { compose } from 'ramda'; + +/** + * Balance sheet table. + */ +function SalesTaxLiabilitySummaryTableRoot({ + // #ownProps + organizationName, +}) { + // Balance sheet context. + const { + salesTaxLiabilitySummary: { table }, + } = useSalesTaxLiabilitySummaryContext(); + + // Retrieve the database columns. + const columns = useSalesTaxLiabilitySummaryColumns(); + + // Retrieve default expanded rows of balance sheet. + const expandedRows = React.useMemo( + () => defaultExpanderReducer(table.rows, 3), + [table], + ); + + return ( + + + + ); +} + +const SalesTaxLiabilitySummaryDataTable = styled(ReportDataTable)` + .table { + .tbody .tr { + .td { + border-bottom: 0; + padding-top: 0.32rem; + padding-bottom: 0.32rem; + } + &:not(.no-results) { + .td { + border-bottom: 0; + padding-top: 0.4rem; + padding-bottom: 0.4rem; + } + &:not(:first-child) .td { + border-top: 1px solid transparent; + } + &.row_type--Total { + font-weight: 500; + + .td { + border-top: 1px solid #bbb; + border-bottom: 3px double #333; + } + } + } + } + } +`; + +export const SalesTaxLiabilitySummaryTable = compose( + withCurrentOrganization(({ organization }) => ({ + organizationName: organization.name, + })), +)(SalesTaxLiabilitySummaryTableRoot); diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/components.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/components.tsx new file mode 100644 index 000000000..8a3c1d793 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/components.tsx @@ -0,0 +1,17 @@ +// @ts-nocheck +import React from 'react'; + +import { useSalesTaxLiabilitySummaryContext } from './SalesTaxLiabilitySummaryBoot'; +import FinancialLoadingBar from '../FinancialLoadingBar'; + +/** + * Balance sheet loading bar. + */ +export function SalesTaxLiabilitySummaryLoadingBar() { + const { isFetching } = useSalesTaxLiabilitySummaryContext(); + + if (!isFetching) { + return null; + } + return ; +} diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/dynamicColumns.ts b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/dynamicColumns.ts new file mode 100644 index 000000000..76c51f811 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/dynamicColumns.ts @@ -0,0 +1,55 @@ +// @ts-nocheck +import React, { useMemo } from 'react'; +import * as R from 'ramda'; +import { getColumnWidth } from '@/utils'; +import { Align } from '@/constants'; + +const getTableCellValueAccessor = (index) => `cells[${index}].value`; + +const taxNameAccessor = R.curry((data, column) => ({ + key: column.key, + Header: column.label, + accessor: getTableCellValueAccessor(column.cell_index), + sticky: 'left', + width: 240, + textOverview: true, +})); + +const taxCodeAccessor = R.curry((data, column) => ({ + key: column.key, + Header: column.label, + accessor: getTableCellValueAccessor(column.cell_index), + sticky: 'left', + width: 240, + textOverview: true, +})); + +const taxableAmountAccessor = R.curry((data, column) => { + const accessor = getTableCellValueAccessor(column.cell_index); + + return { + Header: column.label, + id: column.key, + accessor: getTableCellValueAccessor(column.cell_index), + className: column.key, + width: getColumnWidth(data, accessor, { minWidth: 120 }), + align: Align.Right, + }; +}); + +const dynamicColumnMapper = R.curry((data, column) => { + const taxNameAccessorColumn = taxNameAccessor(data); + const taxCodeAccessorColumn = taxCodeAccessor(data); + const taxableAmountColumn = taxableAmountAccessor(data); + + return R.compose( + R.when(R.pathEq(['key'], 'taxName'), taxNameAccessorColumn), + R.when(R.pathEq(['key'], 'taxCode'), taxCodeAccessorColumn), + R.when(R.pathEq(['key'], 'taxableAmount'), taxableAmountColumn), + R.when(R.pathEq(['key'], 'taxRate'), taxableAmountColumn), + )(column); +}); + +export const salesTaxLiabilitySummaryDynamicColumns = (columns, data) => { + return R.map(dynamicColumnMapper(data), columns); +}; diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/utils.ts b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/utils.ts new file mode 100644 index 000000000..951cdc6c5 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/utils.ts @@ -0,0 +1,89 @@ +// @ts-nocheck +import React from 'react'; +import moment from 'moment'; +import * as Yup from 'yup'; +import { castArray } from 'lodash'; +import intl from 'react-intl-universal'; +import { transformToForm } from '@/utils'; +import { useAppQueryString } from '@/hooks'; +import { salesTaxLiabilitySummaryDynamicColumns } from './dynamicColumns'; +import { useSalesTaxLiabilitySummaryContext } from './SalesTaxLiabilitySummaryBoot'; + +/** + * Retrieves the default sales tax liability summary query. + * @returns {} + */ +export const getDefaultSalesTaxLiablitySummaryQuery = () => ({ + fromDate: moment().startOf('month').format('YYYY-MM-DD'), + toDate: moment().format('YYYY-MM-DD'), + basis: 'cash', +}); + +/** + * Parses the sales tax liability summary query. + */ +const parseSalesTaxLiabilitySummaryQuery = (locationQuery) => { + const defaultQuery = getDefaultSalesTaxLiablitySummaryQuery(); + + const transformed = { + ...defaultQuery, + ...transformToForm(locationQuery, defaultQuery), + }; + return { + ...transformed, + + // Ensures the branches ids is always array. + branchesIds: castArray(transformed.branchesIds), + }; +}; + +/** + * Retrieves the sales tax liability summary query. + */ +export const useSalesTaxLiabilitySummaryQuery = () => { + // Retrieves location query. + const [locationQuery, setLocationQuery] = useAppQueryString(); + + // Merges the default filter query with location URL query. + const parsedQuery = React.useMemo( + () => parseSalesTaxLiabilitySummaryQuery(locationQuery), + [locationQuery], + ); + return [parsedQuery, setLocationQuery]; +}; + +/** + * Retrieves the sales tax liability summary default query. + */ +export const getSalesTaxLiabilitySummaryDefaultQuery = () => { + return { + basic: 'cash', + fromDate: moment().toDate(), + toDate: moment().toDate(), + }; +}; + +/** + * Retrieves the sales tax liability summary query validation. + */ +export const getSalesTaxLiabilitySummaryQueryValidation = () => + Yup.object().shape({ + dateRange: Yup.string().optional(), + fromDate: Yup.date().required().label(intl.get('fromDate')), + toDate: Yup.date() + .min(Yup.ref('fromDate')) + .required() + .label(intl.get('toDate')), + }); + +/** + * Retrieves the sales tax liability summary columns. + * @returns {ITableColumn[]} + */ +export const useSalesTaxLiabilitySummaryColumns = () => { + const { + salesTaxLiabilitySummary: { table }, + } = useSalesTaxLiabilitySummaryContext(); + + return salesTaxLiabilitySummaryDynamicColumns(table.columns, table.rows); +}; diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummary.ts b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummary.ts new file mode 100644 index 000000000..32a5d1552 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummary.ts @@ -0,0 +1,15 @@ +// @ts-nocheck +import { connect } from 'react-redux'; +import { getSalesTaxLiabilitySummaryFilterDrawer } from '@/store/financialStatement/financialStatements.selectors'; + +export default (mapState) => { + const mapStateToProps = (state, props) => { + const mapped = { + salesTaxLiabilitySummaryFilter: + getSalesTaxLiabilitySummaryFilterDrawer(state), + }; + return mapState ? mapState(mapped, state, props) : mapped; + }; + + return connect(mapStateToProps); +}; diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummaryActions.ts b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummaryActions.ts new file mode 100644 index 000000000..3ce7d3046 --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummaryActions.ts @@ -0,0 +1,10 @@ +// @ts-nocheck +import { connect } from 'react-redux'; +import { toggleBalanceSheetFilterDrawer } from '@/store/financialStatement/financialStatements.actions'; + +const mapDispatchToProps = (dispatch) => ({ + toggleSalesTaxLiabilitySummaryFilterDrawer: (toggle) => + dispatch(toggleBalanceSheetFilterDrawer(toggle)), +}); + +export default connect(null, mapDispatchToProps); diff --git a/packages/webapp/src/hooks/query/financialReports.tsx b/packages/webapp/src/hooks/query/financialReports.tsx index 6941fb1d2..68afef7c0 100644 --- a/packages/webapp/src/hooks/query/financialReports.tsx +++ b/packages/webapp/src/hooks/query/financialReports.tsx @@ -444,3 +444,25 @@ export function useTransactionsByReference(query, props) { }, ); } + + +/** + * + */ +export function useSalesTaxLiabilitySummary(query, props) { + return useRequestQuery( + [t.FINANCIAL_REPORT, t.BALANCE_SHEET, 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/routes/dashboard.tsx b/packages/webapp/src/routes/dashboard.tsx index ed66b4373..45260bcb7 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( diff --git a/packages/webapp/src/store/financialStatement/financialStatements.selectors.tsx b/packages/webapp/src/store/financialStatement/financialStatements.selectors.tsx index b5f0c8d10..b46241aa1 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('projectProfitabilitySummary')(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, +); From 0852feecbf978b08c590ad5683811371376528ea Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 1 Sep 2023 20:48:23 +0200 Subject: [PATCH 09/39] fix(server): avoid display total row if no tax rates on sales tax report --- .../SalesTaxLiabilitySummaryTable.ts | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.ts b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.ts index e469cbdb4..204675ce3 100644 --- a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.ts +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.ts @@ -7,15 +7,21 @@ import { } from '@/interfaces/SalesTaxLiabilitySummary'; import { tableRowMapper } from '@/utils'; import { ITableColumn, ITableColumnAccessor, ITableRow } from '@/interfaces'; +import { FinancialSheetStructure } from '../FinancialSheetStructure'; +import { FinancialTable } from '../FinancialTable'; +import AgingReport from '../AgingSummary/AgingReport'; enum IROW_TYPE { TaxRate = 'TaxRate', Total = 'Total', } -export class SalesTaxLiabilitySummaryTable { - data: SalesTaxLiabilitySummaryReportData; - query: SalesTaxLiabilitySummaryQuery; +export class SalesTaxLiabilitySummaryTable extends R.compose( + FinancialSheetStructure, + FinancialTable +)(AgingReport) { + private data: SalesTaxLiabilitySummaryReportData; + private query: SalesTaxLiabilitySummaryQuery; /** * Sales tax liability summary table constructor. @@ -26,6 +32,8 @@ export class SalesTaxLiabilitySummaryTable { data: SalesTaxLiabilitySummaryReportData, query: SalesTaxLiabilitySummaryQuery ) { + super(); + this.data = data; this.query = query; } @@ -119,8 +127,8 @@ export class SalesTaxLiabilitySummaryTable { */ public tableRows(): ITableRow[] { return R.compose( - R.concat(this.taxRatesRows), - R.prepend(this.taxRateTotalRow) + R.unless(R.isEmpty, R.append(this.taxRateTotalRow)), + R.concat(this.taxRatesRows) )([]); } @@ -129,7 +137,7 @@ export class SalesTaxLiabilitySummaryTable { * @returns {ITableColumn[]} */ public tableColumns(): ITableColumn[] { - return [ + return R.compose(this.tableColumnsCellIndexing)([ { label: 'Tax Name', key: 'taxName', @@ -146,6 +154,6 @@ export class SalesTaxLiabilitySummaryTable { label: 'Tax Rate', key: 'taxRate', }, - ]; + ]); } } From eb03a38553b412d8ad404e052c182e1bca112924 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 1 Sep 2023 20:50:22 +0200 Subject: [PATCH 10/39] feat(webapp): wip sales tax summary report --- .../SalesTaxLiabilitySummaryActionsBar.tsx | 2 +- .../SalesTaxLiabilitySummaryBody.tsx | 13 +------------ .../SalesTaxLiabilitySummaryHeader.tsx | 19 ++++++++----------- ...sTaxLiabilitySummaryHeaderGeneralPanel.tsx | 13 +++++++++++++ .../SalesTaxLiabilitySummary/components.tsx | 2 +- .../dynamicColumns.ts | 2 +- .../withSalesTaxLiabilitySummaryActions.ts | 4 ++-- .../financialStatements.actions.tsx | 13 +++++++++++++ .../financialStatements.reducer.tsx | 7 +++++++ .../financialStatements.selectors.tsx | 2 +- .../financialStatements.types.tsx | 1 + 11 files changed, 49 insertions(+), 29 deletions(-) create mode 100644 packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryHeaderGeneralPanel.tsx diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryActionsBar.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryActionsBar.tsx index 2fc716be0..1ffc90c18 100644 --- a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryActionsBar.tsx +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryActionsBar.tsx @@ -27,7 +27,7 @@ function SalesTaxLiabilitySummaryActionsBar({ salesTaxLiabilitySummaryFilter, // #withSalesTaxLiabilitySummaryActions - toggleBalanceSheetFilterDrawer: toggleFilterDrawer, + toggleSalesTaxLiabilitySummaryFilterDrawer: toggleFilterDrawer, // #ownProps numberFormat, diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryBody.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryBody.tsx index 0fadde2d6..6c7b5606f 100644 --- a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryBody.tsx +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryBody.tsx @@ -5,18 +5,13 @@ import { FinancialReportBody } from '../FinancialReportPage'; import { FinancialSheetSkeleton } from '@/components'; import { SalesTaxLiabilitySummaryTable } from './SalesTaxLiabilitySummaryTable'; -import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization'; import { useSalesTaxLiabilitySummaryContext } from './SalesTaxLiabilitySummaryBoot'; -import { compose } from '@/utils'; /** * Sales tax liability summary body. * @returns {React.JSX} */ -function SalesTaxLiabilitySummaryBodyRoot({ - // #withCurrentOrganization - organizationName, -}) { +export function SalesTaxLiabilitySummaryBody() { const { isLoading } = useSalesTaxLiabilitySummaryContext(); return ( @@ -29,9 +24,3 @@ function SalesTaxLiabilitySummaryBodyRoot({ ); } - -export const SalesTaxLiabilitySummaryBody = compose( - withCurrentOrganization(({ organization }) => ({ - organizationName: organization.name, - })), -)(SalesTaxLiabilitySummaryBodyRoot); diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryHeader.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryHeader.tsx index df362738f..5ca3e1d2e 100644 --- a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryHeader.tsx +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryHeader.tsx @@ -2,14 +2,11 @@ import React from 'react'; import styled from 'styled-components'; import moment from 'moment'; -import { Tabs, Tab, Button, Intent } from '@blueprintjs/core'; +import { Button, Intent, Tab, Tabs } from '@blueprintjs/core'; import { Formik, Form } from 'formik'; import { FormattedMessage as T } from '@/components'; import { useFeatureCan } from '@/hooks/state'; -import { Features } from '@/constants'; - -import BalanceSheetHeaderGeneralPanal from './BalanceSheetHeaderGeneralPanal'; import FinancialStatementHeader from '../../FinancialStatements/FinancialStatementHeader'; import { compose, transformToForm } from '@/utils'; @@ -19,6 +16,7 @@ import { } from './utils'; import withSalesTaxLiabilitySummary from './withSalesTaxLiabilitySummary'; import withSalesTaxLiabilitySummaryActions from './withSalesTaxLiabilitySummaryActions'; +import { SalesTaxLiabilitySummaryHeaderGeneral } from './SalesTaxLiabilitySummaryHeaderGeneralPanel'; /** * Sales tax liability summary header. @@ -65,10 +63,9 @@ function SalesTaxLiabilitySummaryHeader({ }; // Detarmines the given feature whether is enabled. const { featureCan } = useFeatureCan(); - const isBranchesFeatureCan = featureCan(Features.Branches); return ( -
- {/* + } - panel={} + panel={} /> - */} +
-
+ ); } @@ -109,7 +106,7 @@ export default compose( withSalesTaxLiabilitySummaryActions, )(SalesTaxLiabilitySummaryHeader); -const BalanceSheetFinancialHeader = styled(FinancialStatementHeader)` +const SalesTaxSummaryFinancialHeader = styled(FinancialStatementHeader)` .bp3-drawer { max-height: 520px; } diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryHeaderGeneralPanel.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryHeaderGeneralPanel.tsx new file mode 100644 index 000000000..071372eec --- /dev/null +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryHeaderGeneralPanel.tsx @@ -0,0 +1,13 @@ +// @ts-nocheck +import React from 'react'; +import FinancialStatementDateRange from '../FinancialStatementDateRange'; +import RadiosAccountingBasis from '../RadiosAccountingBasis'; + +export function SalesTaxLiabilitySummaryHeaderGeneral() { + return ( +
+ + +
+ ); +} diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/components.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/components.tsx index 8a3c1d793..6e4221b60 100644 --- a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/components.tsx +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/components.tsx @@ -5,7 +5,7 @@ import { useSalesTaxLiabilitySummaryContext } from './SalesTaxLiabilitySummaryBo import FinancialLoadingBar from '../FinancialLoadingBar'; /** - * Balance sheet loading bar. + * Sales tax liability summary loading bar. */ export function SalesTaxLiabilitySummaryLoadingBar() { const { isFetching } = useSalesTaxLiabilitySummaryContext(); diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/dynamicColumns.ts b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/dynamicColumns.ts index 76c51f811..54caae5fd 100644 --- a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/dynamicColumns.ts +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/dynamicColumns.ts @@ -1,5 +1,5 @@ // @ts-nocheck -import React, { useMemo } from 'react'; +import React from 'react'; import * as R from 'ramda'; import { getColumnWidth } from '@/utils'; import { Align } from '@/constants'; diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummaryActions.ts b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummaryActions.ts index 3ce7d3046..5453b2aa4 100644 --- a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummaryActions.ts +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/withSalesTaxLiabilitySummaryActions.ts @@ -1,10 +1,10 @@ // @ts-nocheck import { connect } from 'react-redux'; -import { toggleBalanceSheetFilterDrawer } from '@/store/financialStatement/financialStatements.actions'; +import { toggleSalesTaxLiabilitySummaryFilterDrawer } from '@/store/financialStatement/financialStatements.actions'; const mapDispatchToProps = (dispatch) => ({ toggleSalesTaxLiabilitySummaryFilterDrawer: (toggle) => - dispatch(toggleBalanceSheetFilterDrawer(toggle)), + dispatch(toggleSalesTaxLiabilitySummaryFilterDrawer(toggle)), }); export default connect(null, mapDispatchToProps); 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 b46241aa1..1c44babfd 100644 --- a/packages/webapp/src/store/financialStatement/financialStatements.selectors.tsx +++ b/packages/webapp/src/store/financialStatement/financialStatements.selectors.tsx @@ -87,7 +87,7 @@ export const projectProfitabilitySummaryFilterDrawerSelector = (state) => { }; export const salesTaxLiabilitySummaryFilterDrawerSelector = (state) => { - return filterDrawerByTypeSelector('projectProfitabilitySummary')(state); + return filterDrawerByTypeSelector('salesTaxLiabilitySummary')(state); }; /** 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', }; From 801ea5dfdbc580317f7e002d942c93db13f72cb4 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 2 Sep 2023 01:50:24 +0200 Subject: [PATCH 11/39] feat: wip sales tax summary report --- .../SalesTaxLiabilitySummary/index.ts | 4 ---- .../interfaces/SalesTaxLiabilitySummary.ts | 4 +++- .../SalesTaxLiabilitySummary.ts | 14 ++++++++++-- .../SalesTaxLiabilitySummaryTable.ts | 22 ++++++++++++------- 4 files changed, 29 insertions(+), 15 deletions(-) diff --git a/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts b/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts index a9399c1dd..42e2c5ecc 100644 --- a/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts +++ b/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts @@ -5,13 +5,9 @@ import asyncMiddleware from '@/api/middleware/asyncMiddleware'; import BaseFinancialReportController from '../BaseFinancialReportController'; import { AbilitySubject, ReportsAction } from '@/interfaces'; import CheckPolicies from '@/api/middleware/CheckPolicies'; -import HasTenancyService from '@/services/Tenancy/TenancyService'; import { SalesTaxLiabilitySummaryService } from '@/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService'; export default class SalesTaxLiabilitySummary extends BaseFinancialReportController { - @Inject() - private tenancy: HasTenancyService; - @Inject() private salesTaxLiabilitySummaryService: SalesTaxLiabilitySummaryService; diff --git a/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts b/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts index 301cdd76c..1a036ce1c 100644 --- a/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts +++ b/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts @@ -13,13 +13,15 @@ export interface SalesTaxLiabilitySummaryAmount { export interface SalesTaxLiabilitySummaryTotal { taxableAmount: SalesTaxLiabilitySummaryAmount; taxAmount: SalesTaxLiabilitySummaryAmount; + collectedTaxAmount: SalesTaxLiabilitySummaryAmount; } export interface SalesTaxLiabilitySummaryRate { taxName: string; - taxCode: string; taxableAmount: SalesTaxLiabilitySummaryAmount; taxAmount: SalesTaxLiabilitySummaryAmount; + taxPercentage: any; + collectedTaxAmount: SalesTaxLiabilitySummaryAmount; } export enum SalesTaxLiabilitySummaryTableRowType { diff --git a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts index 3c658dd8a..9a84849a2 100644 --- a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummary.ts @@ -53,11 +53,19 @@ export class SalesTaxLiabilitySummary extends FinancialSheet { : 0; const salesTaxAmount = salesTax ? salesTax.credit - salesTax.debit : 0; + // Calculates the tax percentage. + const taxPercentage = salesTaxAmount / payableTaxAmount; + const taxPercentageRate = taxPercentage / 100; + + // Calculates the payable tax amount. + const collectedTaxAmount = payableTax ? payableTax.debit : 0; + return { - taxName: taxRate.name, - taxCode: taxRate.code, + taxName: `${taxRate.name} (${taxRate.rate}%)`, taxableAmount: this.getAmountMeta(salesTaxAmount), taxAmount: this.getAmountMeta(payableTaxAmount), + taxPercentage: this.getPercentageAmountMeta(taxPercentageRate), + collectedTaxAmount: this.getAmountMeta(collectedTaxAmount), }; }; @@ -79,10 +87,12 @@ export class SalesTaxLiabilitySummary extends FinancialSheet { ): 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), }; }; diff --git a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.ts b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.ts index 204675ce3..7da959a9a 100644 --- a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.ts +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.ts @@ -44,9 +44,10 @@ export class SalesTaxLiabilitySummaryTable extends R.compose( */ private get taxRateRowAccessor() { return [ - { key: 'taxName', value: 'taxName' }, - { key: 'taxCode', accessor: 'taxCode' }, + { key: 'taxName', accessor: 'taxName' }, + { key: 'taxPercentage', accessor: 'taxPercentage.formattedAmount' }, { key: 'taxableAmount', accessor: 'taxableAmount.formattedAmount' }, + { key: 'collectedTax', accessor: 'collectedTaxAmount.formattedAmount' }, { key: 'taxAmount', accessor: 'taxAmount.formattedAmount' }, ]; } @@ -57,9 +58,10 @@ export class SalesTaxLiabilitySummaryTable extends R.compose( */ private get taxRateTotalRowAccessors() { return [ - { key: 'taxName', value: '' }, - { key: 'taxCode', accessor: 'taxCode' }, + { key: 'taxName', value: 'Total' }, + { key: 'taxPercentage', value: '' }, { key: 'taxableAmount', accessor: 'taxableAmount.formattedAmount' }, + { key: 'collectedTax', accessor: 'collectedTaxAmount.formattedAmount' }, { key: 'taxAmount', accessor: 'taxAmount.formattedAmount' }, ]; } @@ -122,7 +124,7 @@ export class SalesTaxLiabilitySummaryTable extends R.compose( } /** - * Retrieve the table rows. + * Retrieves the table rows. * @returns {ITableRow[]} */ public tableRows(): ITableRow[] { @@ -133,7 +135,7 @@ export class SalesTaxLiabilitySummaryTable extends R.compose( } /** - * Retrieve the table columns. + * Retrieves the table columns. * @returns {ITableColumn[]} */ public tableColumns(): ITableColumn[] { @@ -143,13 +145,17 @@ export class SalesTaxLiabilitySummaryTable extends R.compose( key: 'taxName', }, { - label: 'Tax Code', - key: 'taxCode', + label: 'Tax Percentage', + key: 'taxPercentage', }, { label: 'Taxable Amount', key: 'taxableAmount', }, + { + label: 'Collected Tax', + key: 'collectedTax', + }, { label: 'Tax Rate', key: 'taxRate', From bb7cf41e3e6ae7ea6298c151c41c81bbc3b23985 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 2 Sep 2023 01:51:28 +0200 Subject: [PATCH 12/39] feat: add sales tax summary report to reports list --- .../src/constants/financialReportsMenu.tsx | 35 +++++++------------ packages/webapp/src/constants/sidebarMenu.tsx | 15 ++++++++ .../FinancialStatements/FinancialReports.tsx | 13 +------ 3 files changed, 28 insertions(+), 35 deletions(-) diff --git a/packages/webapp/src/constants/financialReportsMenu.tsx b/packages/webapp/src/constants/financialReportsMenu.tsx index 8f749728f..c659462bd 100644 --- a/packages/webapp/src/constants/financialReportsMenu.tsx +++ b/packages/webapp/src/constants/financialReportsMenu.tsx @@ -85,18 +85,8 @@ export const financialReportMenus = [ subject: AbilitySubject.Report, ability: ReportsAction.READ_PROFIT_LOSS, }, - { - 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, - } ], }, -]; - -export const SalesAndPurchasesReportMenus = [ { sectionTitle: , reports: [ @@ -126,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: ( @@ -196,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..53189dd1f 100644 --- a/packages/webapp/src/constants/sidebarMenu.tsx +++ b/packages/webapp/src/constants/sidebarMenu.tsx @@ -741,6 +741,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/FinancialStatements/FinancialReports.tsx b/packages/webapp/src/containers/FinancialStatements/FinancialReports.tsx index 984c86868..bde19bad6 100644 --- a/packages/webapp/src/containers/FinancialStatements/FinancialReports.tsx +++ b/packages/webapp/src/containers/FinancialStatements/FinancialReports.tsx @@ -3,11 +3,7 @@ import React from 'react'; import { Link } from 'react-router-dom'; import { For, DashboardInsider } from '@/components'; import useFilterFinancialReports from './FilterFinancialReports'; - -import { - financialReportMenus, - SalesAndPurchasesReportMenus, -} from '@/constants/financialReportsMenu'; +import { financialReportMenus } from '@/constants/financialReportsMenu'; import '@/style/pages/FinancialStatements/FinancialSheets.scss'; @@ -39,18 +35,11 @@ function FinancialReportsSection({ sectionTitle, reports }) { */ export default function FinancialReports() { const financialReportMenu = useFilterFinancialReports(financialReportMenus); - const SalesAndPurchasesReportMenu = useFilterFinancialReports( - SalesAndPurchasesReportMenus, - ); return (
-
); From b49b45fb4341d2da9332dc5ea299895ec9162cc1 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sat, 2 Sep 2023 01:52:07 +0200 Subject: [PATCH 13/39] feat: wip sales tax summry report --- .../SalesTaxLiabilitySummaryTable.tsx | 10 ++++++++++ .../SalesTaxLiabilitySummary/dynamicColumns.ts | 15 +++------------ 2 files changed, 13 insertions(+), 12 deletions(-) diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.tsx index 60b102354..9f1a0ec1d 100644 --- a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.tsx +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.tsx @@ -81,6 +81,16 @@ const SalesTaxLiabilitySummaryDataTable = styled(ReportDataTable)` border-bottom: 3px double #333; } } + &.row_type--TaxRate { + .td { + &.td-taxPercentage, + &.td-taxableAmount, + &.td-collectedTax, + &.td-taxRate { + color: #444; + } + } + } } } } diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/dynamicColumns.ts b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/dynamicColumns.ts index 54caae5fd..e716470d3 100644 --- a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/dynamicColumns.ts +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/dynamicColumns.ts @@ -11,16 +11,7 @@ const taxNameAccessor = R.curry((data, column) => ({ Header: column.label, accessor: getTableCellValueAccessor(column.cell_index), sticky: 'left', - width: 240, - textOverview: true, -})); - -const taxCodeAccessor = R.curry((data, column) => ({ - key: column.key, - Header: column.label, - accessor: getTableCellValueAccessor(column.cell_index), - sticky: 'left', - width: 240, + width: 300, textOverview: true, })); @@ -39,14 +30,14 @@ const taxableAmountAccessor = R.curry((data, column) => { const dynamicColumnMapper = R.curry((data, column) => { const taxNameAccessorColumn = taxNameAccessor(data); - const taxCodeAccessorColumn = taxCodeAccessor(data); const taxableAmountColumn = taxableAmountAccessor(data); return R.compose( R.when(R.pathEq(['key'], 'taxName'), taxNameAccessorColumn), - R.when(R.pathEq(['key'], 'taxCode'), taxCodeAccessorColumn), R.when(R.pathEq(['key'], 'taxableAmount'), taxableAmountColumn), R.when(R.pathEq(['key'], 'taxRate'), taxableAmountColumn), + R.when(R.pathEq(['key'], 'taxPercentage'), taxableAmountColumn), + R.when(R.pathEq(['key'], 'collectedTax'), taxableAmountColumn), )(column); }); From ac072d29fc313ef4f14b196f6cac21497a45b123 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 4 Sep 2023 18:39:49 +0200 Subject: [PATCH 14/39] feat(server): wip sale invoice tax rate GL entries --- .../20230810191606_create_tax_rates.js | 1 + packages/server/src/interfaces/Account.ts | 3 ++ packages/server/src/interfaces/Ledger.ts | 2 + .../server/src/models/AccountTransaction.ts | 52 +++++++++++++++++++ .../services/Accounting/JournalCommands.ts | 9 +--- .../server/src/services/Accounting/Ledger.ts | 3 ++ .../server/src/services/Accounting/utils.ts | 3 ++ .../Sales/Invoices/InvoiceGLEntries.ts | 26 +++++++--- .../PaymentReceivesApplication.ts | 4 +- 9 files changed, 87 insertions(+), 16 deletions(-) diff --git a/packages/server/src/database/migrations/20230810191606_create_tax_rates.js b/packages/server/src/database/migrations/20230810191606_create_tax_rates.js index d83efd765..717567d16 100644 --- a/packages/server/src/database/migrations/20230810191606_create_tax_rates.js +++ b/packages/server/src/database/migrations/20230810191606_create_tax_rates.js @@ -42,6 +42,7 @@ exports.up = (knex) => { .unsigned() .references('id') .inTable('tax_rates'); + table.decimal('tax_rate').unsigned(); }); }; diff --git a/packages/server/src/interfaces/Account.ts b/packages/server/src/interfaces/Account.ts index 3d3ce47a7..4de176c37 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 {} diff --git a/packages/server/src/interfaces/Ledger.ts b/packages/server/src/interfaces/Ledger.ts index 57a17f6c5..0f6379676 100644 --- a/packages/server/src/interfaces/Ledger.ts +++ b/packages/server/src/interfaces/Ledger.ts @@ -47,7 +47,9 @@ export interface ILedgerEntry { itemId?: number; branchId?: number; projectId?: number; + taxRateId?: number; + taxRate?: number; entryId?: number; createdAt?: Date; diff --git a/packages/server/src/models/AccountTransaction.ts b/packages/server/src/models/AccountTransaction.ts index a8ad848f7..47fbd62ec 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 @@ -28,6 +32,54 @@ export default class AccountTransaction extends TenantModel { return ['referenceTypeFormatted']; } + /** + * Retrieves the credit amount in foreign currency. + * @return {number} + */ + get creditFcy() { + return this.credit; + } + + /** + * Retrieves the debit amount in foreign currency. + * @return {number} + */ + get debitFcy() { + return this.debit; + } + + /** + * Retrieves the credit amount in base currency. + * @return {number} + */ + get creditBcy() { + return this.credit * this.exchangeRate; + } + + /** + * Retrieves the debit amount in base currency. + * @return {number} + */ + get debitBcy() { + return this.debit * this.exchangeRate; + } + + /** + * Retrieves the tax amount in foreign currency. + * @return {number} + */ + get taxAmountFcy() { + return (this.creditFcy - this.debitFcy) * this.taxRate; + } + + /** + * Retrieves the tax amount in base currency. + * @return {number} + */ + get taxAmountBcy() { + return (this.creditBcy - this.debitBcy) * this.taxRate; + } + /** * Retrieve formatted reference type. * @return {string} 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/Sales/Invoices/InvoiceGLEntries.ts b/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts index 8371aed86..6017e643e 100644 --- a/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts +++ b/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts @@ -53,7 +53,7 @@ export class SaleInvoiceGLEntries { saleInvoice, ARAccount.id, taxPayableAccount.id - ); + ); // Commits the ledger entries to the storage as UOW. await this.ledegrRepository.commit(tenantId, ledger, trx); }; @@ -190,6 +190,8 @@ export class SaleInvoiceGLEntries { itemQuantity: entry.quantity, accountNormal: AccountNormal.CREDIT, projectId: entry.projectId || saleInvoice.projectId, + taxRateId: entry.taxRateId, + taxRate: entry.taxRate, }; } ); @@ -201,15 +203,22 @@ export class SaleInvoiceGLEntries { * @returns {ILedgerEntry} */ private getInvoiceTaxEntry = R.curry( - (saleInvoice: ISaleInvoice, taxPayableAccountId: number): ILedgerEntry => { + ( + saleInvoice: ISaleInvoice, + taxPayableAccountId: number, + entry: IItemEntry, + index: number + ): ILedgerEntry => { const commonEntry = this.getInvoiceGLCommonEntry(saleInvoice); return { ...commonEntry, - credit: saleInvoice.taxAmountWithheld, + credit: entry.taxAmount, accountId: taxPayableAccountId, - index: saleInvoice.entries.length + 3, + index: index + 3, accountNormal: AccountNormal.CREDIT, + taxRateId: entry.taxRateId, + taxRate : entry.taxRate, }; } ); @@ -230,10 +239,13 @@ export class SaleInvoiceGLEntries { ARAccountId ); const transformItemEntry = this.getInvoiceItemEntry(saleInvoice); - + const transformTaxEntry = this.getInvoiceTaxEntry( + saleInvoice, + taxPayableAccountId + ); const creditEntries = saleInvoice.entries.map(transformItemEntry); - const taxEntry = this.getInvoiceTaxEntry(saleInvoice, taxPayableAccountId); + const taxEntries = saleInvoice.entries.map(transformTaxEntry); - return [receivableEntry, ...creditEntries, taxEntry]; + return [receivableEntry, ...creditEntries, ...taxEntries]; }; } 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} From 983ceb5cc64476fccc5262e53e4983a59ae1ec29 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Wed, 6 Sep 2023 14:01:40 +0200 Subject: [PATCH 15/39] feat: sale invoice model tax attributes --- .../api/controllers/Sales/SalesInvoices.ts | 2 +- .../20230810191606_create_tax_rates.js | 3 + packages/server/src/interfaces/ItemEntry.ts | 5 + packages/server/src/interfaces/SaleInvoice.ts | 21 ++- .../server/src/lib/Transformer/Transformer.ts | 4 +- packages/server/src/models/ItemEntry.ts | 76 +++++++-- packages/server/src/models/SaleInvoice.ts | 158 +++++++----------- .../CommandSaleInvoiceDTOTransformer.ts | 39 ++++- .../Sales/Invoices/InvoiceGLEntries.ts | 4 +- .../SaleInvoiceTaxEntryTransformer.ts | 36 +++- .../Sales/Invoices/SaleInvoiceTransformer.ts | 128 +++++++++++--- .../TaxRates/ItemEntriesTaxTransactions.ts | 1 - packages/server/src/utils/index.ts | 11 +- packages/server/src/utils/taxRate.ts | 19 +++ 14 files changed, 346 insertions(+), 161 deletions(-) create mode 100644 packages/server/src/utils/taxRate.ts diff --git a/packages/server/src/api/controllers/Sales/SalesInvoices.ts b/packages/server/src/api/controllers/Sales/SalesInvoices.ts index e55aeeb7d..4a449f4ca 100644 --- a/packages/server/src/api/controllers/Sales/SalesInvoices.ts +++ b/packages/server/src/api/controllers/Sales/SalesInvoices.ts @@ -169,7 +169,7 @@ export default class SaleInvoicesController extends BaseController { check('branch_id').optional({ nullable: true }).isNumeric().toInt(), check('project_id').optional({ nullable: true }).isNumeric().toInt(), - check('is_tax_exclusive').optional().isBoolean().toBoolean(), + check('is_inclusive_tax').optional().isBoolean().toBoolean(), check('entries').exists().isArray({ min: 1 }), check('entries.*.index').exists().isNumeric().toInt(), diff --git a/packages/server/src/database/migrations/20230810191606_create_tax_rates.js b/packages/server/src/database/migrations/20230810191606_create_tax_rates.js index 717567d16..7baf6be4e 100644 --- a/packages/server/src/database/migrations/20230810191606_create_tax_rates.js +++ b/packages/server/src/database/migrations/20230810191606_create_tax_rates.js @@ -43,6 +43,9 @@ exports.up = (knex) => { .references('id') .inTable('tax_rates'); table.decimal('tax_rate').unsigned(); + }) + .table('sales_invoices', (table) => { + table.rename('balance', 'amount'); }); }; diff --git a/packages/server/src/interfaces/ItemEntry.ts b/packages/server/src/interfaces/ItemEntry.ts index bb67df9e8..aab0b154d 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; diff --git a/packages/server/src/interfaces/SaleInvoice.ts b/packages/server/src/interfaces/SaleInvoice.ts index 4aa072655..7ef8fdea2 100644 --- a/packages/server/src/interfaces/SaleInvoice.ts +++ b/packages/server/src/interfaces/SaleInvoice.ts @@ -5,7 +5,8 @@ import { IItemEntry, IItemEntryDTO } from './ItemEntry'; export interface ISaleInvoice { id: number; - balance: number; + amount: number; + amountLocal?: number; paymentAmount: number; currencyCode: string; exchangeRate?: number; @@ -27,15 +28,21 @@ export interface ISaleInvoice { branchId?: number; projectId?: number; - localAmount?: number; - - localWrittenoffAmount?: number; + writtenoffAmount?: number; + writtenoffAmountLocal?: number; writtenoffExpenseAccountId?: number; - writtenoffExpenseAccount?: IAccount; taxAmountWithheld: number; - taxes: ITaxTransaction[] + taxAmountWithheldLocal: number; + taxes: ITaxTransaction[]; + + total: number; + totalLocal: number; + + subtotal: number; + subtotalLocal: number; + subtotalExludingTax: number; } export interface ISaleInvoiceDTO { @@ -54,6 +61,8 @@ export interface ISaleInvoiceDTO { warehouseId?: number | null; projectId?: number; branchId?: number | null; + + isInclusiveTax?: boolean; } export interface ISaleInvoiceCreateDTO extends ISaleInvoiceDTO { 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/models/ItemEntry.ts b/packages/server/src/models/ItemEntry.ts index 2d7e5de95..7ac7910e0 100644 --- a/packages/server/src/models/ItemEntry.ts +++ b/packages/server/src/models/ItemEntry.ts @@ -1,11 +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'; @@ -13,24 +19,66 @@ 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', 'taxAmount']; + 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; + /** + * Item entry amount including tax. + * @returns {number} + */ + get amountInclusingTax() { + return this.isInclusiveTax ? this.amount : this.amount + this.taxAmount; + } - return discount ? total - total * discount * 0.01 : total; + /** + * 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); } /** @@ -46,9 +94,14 @@ export default class ItemEntry extends TenantModel { * @returns {number} */ get taxAmount() { - return this.amount * this.tagRateFraction; + 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'); @@ -104,6 +157,9 @@ export default class ItemEntry extends TenantModel { }, }, + /** + * Sale receipt reference. + */ receipt: { relation: Model.BelongsToOneRelation, modelClass: SaleReceipt.default, @@ -114,7 +170,7 @@ export default class ItemEntry extends TenantModel { }, /** - * + * Project task reference. */ projectTaskRef: { relation: Model.HasManyRelation, @@ -126,7 +182,7 @@ export default class ItemEntry extends TenantModel { }, /** - * + * Project expense reference. */ projectExpenseRef: { relation: Model.HasManyRelation, @@ -138,7 +194,7 @@ export default class ItemEntry extends TenantModel { }, /** - * + * Project bill reference. */ projectBillRef: { relation: Model.HasManyRelation, diff --git a/packages/server/src/models/SaleInvoice.ts b/packages/server/src/models/SaleInvoice.ts index 5bbeb51fc..828fb08c7 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,10 +13,16 @@ export default class SaleInvoice extends mixin(TenantModel, [ CustomViewBaseModel, ModelSearchable, ]) { - taxAmountWithheld: number; - balance: number; - paymentAmount: number; - exchangeRate: number; + public taxAmountWithheld: number; + public amount: 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 @@ -32,6 +38,9 @@ export default class SaleInvoice extends mixin(TenantModel, [ return ['created_at', 'updated_at']; } + /** + * + */ get pluralName() { return 'asdfsdf'; } @@ -41,140 +50,82 @@ 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 total FCY. + * Subtotal. (Tax inclusive) if the tax inclusive is enabled. * @returns {number} */ - get totalFcy() { - return this.amountFcy + this.taxAmountWithheldFcy; + get subtotal() { + return this.amount; } /** - * Invoice total BCY. + * Subtotal in base currency. (Tax inclusive) if the tax inclusive is enabled. * @returns {number} */ - get totalBcy() { - return this.amountBcy + this.taxAmountWithheldBcy; + get subtotalLocal() { + return this.amount * this.exchangeRate; } /** - * Tax amount withheld FCY. + * Sale invoice amount excluding tax. * @returns {number} */ - get taxAmountWithheldFcy() { - return this.taxAmountWithheld; + get subtotalExludingTax() { + return this.isInclusiveTax + ? this.subtotal - this.taxAmountWithheld + : this.subtotal; } /** - * Tax amount withheld BCY. + * Tax amount withheld in base currency. * @returns {number} */ - get taxAmountWithheldBcy() { - return this.taxAmountWithheld; + get taxAmountWithheldLocal() { + return this.taxAmountWithheld * this.exchangeRate; } /** - * Subtotal FCY. + * Invoice total. (Tax included) * @returns {number} */ - get subtotalFcy() { - return this.amountFcy; - } - - /** - * Subtotal BCY. - * @returns {number} - */ - get subtotalBcy() { - return this.amountBcy; - } - - /** - * Invoice due amount FCY. - * @returns {number} - */ - get dueAmountFcy() { - return this.amountFcy - this.paymentAmountFcy; - } - - /** - * Invoice due amount BCY. - * @returns {number} - */ - get dueAmountBcy() { - return this.amountBcy - this.paymentAmountBcy; - } - - /** - * Invoice amount FCY. - * @returns {number} - */ - get amountFcy() { - return this.balance; - } - - /** - * Invoice amount BCY. - * @returns {number} - */ - get amountBcy() { - return this.balance * this.exchangeRate; - } - - /** - * Invoice payment amount FCY. - * @returns {number} - */ - get paymentAmountFcy() { - return this.paymentAmount; - } - - /** - * Invoice payment amount BCY. - * @returns {number} - */ - get paymentAmountBcy() { - return this.paymentAmount * this.exchangeRate; - } - - /** - * - */ get total() { - return this.balance + this.taxAmountWithheld; + return this.isInclusiveTax + ? this.subtotal + : this.subtotal + this.taxAmountWithheld; } /** - * Invoice amount in local currency. + * Invoice total in local currency. (Tax included) * @returns {number} */ - get localAmount() { + get totalLocal() { return this.total * this.exchangeRate; } - /** - * Invoice local written-off amount. - * @returns {number} - */ - get localWrittenoffAmount() { - return this.writtenoffAmount * this.exchangeRate; - } - /** * Detarmines whether the invoice is delivered. * @return {boolean} @@ -205,7 +156,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); } /** @@ -213,7 +164,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; } /** @@ -491,7 +442,7 @@ export default class SaleInvoice extends mixin(TenantModel, [ }, /** - * + * Invoice may has associated cost transactions. */ costTransactions: { relation: Model.HasManyRelation, @@ -506,7 +457,7 @@ export default class SaleInvoice extends mixin(TenantModel, [ }, /** - * + * Invoice may has associated payment entries. */ paymentEntries: { relation: Model.HasManyRelation, @@ -529,6 +480,9 @@ export default class SaleInvoice extends mixin(TenantModel, [ }, }, + /** + * Invoice may has associated written-off expense account. + */ writtenoffExpenseAccount: { relation: Model.BelongsToOneRelation, modelClass: Account.default, @@ -539,7 +493,7 @@ export default class SaleInvoice extends mixin(TenantModel, [ }, /** - * + * Invoice may has associated tax rate transactions. */ taxes: { relation: Model.HasManyRelation, diff --git a/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts b/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts index 867e7c660..ddb942553 100644 --- a/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/CommandSaleInvoiceDTOTransformer.ts @@ -13,17 +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; @@ -55,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); @@ -72,6 +67,7 @@ export class CommandSaleInvoiceDTOTransformer { const initialEntries = saleInvoiceDTO.entries.map((entry) => ({ referenceType: 'SaleInvoice', + isInclusiveTax: saleInvoiceDTO.isInclusiveTax, ...entry, })); const entries = await composeAsync( @@ -87,7 +83,7 @@ export class CommandSaleInvoiceDTOTransformer { ['invoiceDate', 'dueDate'] ), // Avoid rewrite the deliver date in edit mode when already published. - balance, + amount, currencyCode: customer.currencyCode, exchangeRate: saleInvoiceDTO.exchangeRate || 1, ...(saleInvoiceDTO.delivered && @@ -107,4 +103,29 @@ export class CommandSaleInvoiceDTOTransformer { 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/InvoiceGLEntries.ts b/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts index 6017e643e..6f38ad454 100644 --- a/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts +++ b/packages/server/src/services/Sales/Invoices/InvoiceGLEntries.ts @@ -93,7 +93,7 @@ export class SaleInvoiceGLEntries { 'SaleInvoice', trx ); - }; +}; /** * Retrieves the given invoice ledger. @@ -156,7 +156,7 @@ export class SaleInvoiceGLEntries { return { ...commonEntry, - debit: saleInvoice.totalBcy, + debit: saleInvoice.totalLocal, accountId: ARAccountId, contactId: saleInvoice.customerId, accountNormal: AccountNormal.DEBIT, diff --git a/packages/server/src/services/Sales/Invoices/SaleInvoiceTaxEntryTransformer.ts b/packages/server/src/services/Sales/Invoices/SaleInvoiceTaxEntryTransformer.ts index b61242ca5..6f028423d 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoiceTaxEntryTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoiceTaxEntryTransformer.ts @@ -1,4 +1,7 @@ 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 { /** @@ -6,7 +9,14 @@ export class SaleInvoiceTaxEntryTransformer extends Transformer { * @returns {Array} */ public includeAttributes = (): string[] => { - return ['name', 'taxRateCode', 'raxRate', 'taxRateId']; + return [ + 'name', + 'taxRateCode', + 'taxRate', + 'taxRateId', + 'taxRateAmount', + 'taxRateAmountFormatted', + ]; }; /** @@ -31,7 +41,7 @@ export class SaleInvoiceTaxEntryTransformer extends Transformer { * @param taxEntry * @returns {number} */ - protected raxRate = (taxEntry) => { + protected taxRate = (taxEntry) => { return taxEntry.taxAmount || taxEntry.taxRate.rate; }; @@ -43,4 +53,26 @@ export class SaleInvoiceTaxEntryTransformer extends Transformer { 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 c40320c33..ffb2d8391 100644 --- a/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts +++ b/packages/server/src/services/Sales/Invoices/SaleInvoiceTransformer.ts @@ -9,13 +9,19 @@ 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', ]; }; @@ -25,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); }; @@ -34,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, }); @@ -65,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, }); @@ -76,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, }); @@ -87,15 +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()); + return this.item(invoice.taxes, new SaleInvoiceTaxEntryTransformer(), { + amount: invoice.amount, + isInclusiveTax: invoice.isInclusiveTax, + currencyCode: invoice.currencyCode, + }); }; } diff --git a/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts b/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts index a85d1c998..a9bd5c683 100644 --- a/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts +++ b/packages/server/src/services/TaxRates/ItemEntriesTaxTransactions.ts @@ -1,6 +1,5 @@ import { Inject, Service } from 'typedi'; import { keyBy, sumBy } from 'lodash'; -import * as R from 'ramda'; import { ItemEntry } from '@/models'; import HasTenancyService from '../Tenancy/TenancyService'; 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; +}; From 7657337c4fa8c50476e73ae0f61fed861f3bf130 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Fri, 8 Sep 2023 19:49:46 +0200 Subject: [PATCH 16/39] feat: sales tax report query --- .../SalesTaxLiabilitySummaryActionsBar.tsx | 8 +++----- .../SalesTaxLiabilitySummaryBoot.tsx | 1 + .../SalesTaxLiabilitySummaryHeader.tsx | 7 ++++--- .../SalesTaxLiabilitySummaryTable.tsx | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryActionsBar.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryActionsBar.tsx index 1ffc90c18..9c551ff24 100644 --- a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryActionsBar.tsx +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryActionsBar.tsx @@ -33,19 +33,17 @@ function SalesTaxLiabilitySummaryActionsBar({ numberFormat, onNumberFormatSubmit, }) { - const { isLoading, refetchBalanceSheet } = + const { isLoading, refetchSalesTaxLiabilitySummary } = useSalesTaxLiabilitySummaryContext(); // Handle filter toggle click. const handleFilterToggleClick = () => { toggleFilterDrawer(); }; - - // Handle recalculate the report button. + // Handle re-calculate the report button. const handleRecalcReport = () => { - refetchBalanceSheet(); + refetchSalesTaxLiabilitySummary(); }; - // Handle number format form submit. const handleNumberFormatSubmit = (values) => { saveInvoke(onNumberFormatSubmit, values); diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryBoot.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryBoot.tsx index 98190eb3d..c4329d307 100644 --- a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryBoot.tsx +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryBoot.tsx @@ -26,6 +26,7 @@ function SalesTaxLiabilitySummaryBoot({ filter, ...props }) { const provider = { salesTaxLiabilitySummary, + refetchSalesTaxLiabilitySummary: refetch, isFetching, isLoading, query, diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryHeader.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryHeader.tsx index 5ca3e1d2e..42213c2ee 100644 --- a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryHeader.tsx +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryHeader.tsx @@ -34,6 +34,9 @@ function SalesTaxLiabilitySummaryHeader({ }) { const defaultValues = getDefaultSalesTaxLiablitySummaryQuery(); + // Validation schema. + const validationSchema = getSalesTaxLiabilitySummaryQueryValidation(); + // Filter form initial values. const initialValues = transformToForm( { @@ -44,8 +47,6 @@ function SalesTaxLiabilitySummaryHeader({ }, defaultValues, ); - // Validation schema. - const validationSchema = getSalesTaxLiabilitySummaryQueryValidation(); // Handle form submit. const handleSubmit = (values, actions) => { @@ -108,6 +109,6 @@ export default compose( const SalesTaxSummaryFinancialHeader = styled(FinancialStatementHeader)` .bp3-drawer { - max-height: 520px; + max-height: 320px; } `; diff --git a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.tsx b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.tsx index 9f1a0ec1d..a78ee4a8c 100644 --- a/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.tsx +++ b/packages/webapp/src/containers/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryTable.tsx @@ -1,7 +1,7 @@ // @ts-nocheck import React from 'react'; import styled from 'styled-components'; -import intl from 'react-intl-universal'; +import { compose } from 'ramda'; import { TableStyle } from '@/constants'; import { ReportDataTable, FinancialSheet } from '@/components'; @@ -9,7 +9,6 @@ import { defaultExpanderReducer, tableRowTypesToClassnames } from '@/utils'; import { useSalesTaxLiabilitySummaryContext } from './SalesTaxLiabilitySummaryBoot'; import withCurrentOrganization from '@/containers/Organization/withCurrentOrganization'; import { useSalesTaxLiabilitySummaryColumns } from './utils'; -import { compose } from 'ramda'; /** * Balance sheet table. @@ -20,7 +19,7 @@ function SalesTaxLiabilitySummaryTableRoot({ }) { // Balance sheet context. const { - salesTaxLiabilitySummary: { table }, + salesTaxLiabilitySummary: { table, query }, } = useSalesTaxLiabilitySummaryContext(); // Retrieve the database columns. @@ -36,7 +35,8 @@ function SalesTaxLiabilitySummaryTableRoot({ Date: Mon, 11 Sep 2023 20:46:46 +0200 Subject: [PATCH 17/39] feat: tax rate transformer --- .../SalesTaxLiabilitySummary/index.ts | 6 ++- .../interfaces/SalesTaxLiabilitySummary.ts | 5 +++ packages/server/src/models/ItemEntry.ts | 13 +++++++ .../SalesTaxLiabilitySummaryService.ts | 37 ++++++++++++++++++- .../services/Sales/Invoices/GetSaleInvoice.ts | 1 + .../src/services/TaxRates/GetTaxRate.ts | 12 +++++- .../src/services/TaxRates/GetTaxRates.ts | 12 +++++- .../services/TaxRates/TaxRateTransformer.ts | 20 ++++++++++ 8 files changed, 101 insertions(+), 5 deletions(-) create mode 100644 packages/server/src/services/TaxRates/TaxRateTransformer.ts diff --git a/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts b/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts index 42e2c5ecc..399fbeb90 100644 --- a/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts +++ b/packages/server/src/api/controllers/FinancialStatements/SalesTaxLiabilitySummary/index.ts @@ -67,6 +67,8 @@ export default class SalesTaxLiabilitySummary extends BaseFinancialReportControl return res.status(200).send({ table: salesTaxLiabilityTable.table, + query: salesTaxLiabilityTable.query, + meta: salesTaxLiabilityTable.meta, }); case 'json': default: @@ -76,7 +78,9 @@ export default class SalesTaxLiabilitySummary extends BaseFinancialReportControl filter ); return res.status(200).send({ - data: salesTaxLiability, + data: salesTaxLiability.data, + query: salesTaxLiability.query, + meta: salesTaxLiability.meta, }); } } catch (error) { diff --git a/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts b/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts index 1a036ce1c..533bba840 100644 --- a/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts +++ b/packages/server/src/interfaces/SalesTaxLiabilitySummary.ts @@ -43,3 +43,8 @@ export type SalesTaxLiabilitySummarySalesById = Record< string, { taxRateId: number; credit: number; debit: number } >; + +export interface SalesTaxLiabilitySummaryMeta { + organizationName: string; + baseCurrency: string; +} diff --git a/packages/server/src/models/ItemEntry.ts b/packages/server/src/models/ItemEntry.ts index 7ac7910e0..43b3f9376 100644 --- a/packages/server/src/models/ItemEntry.ts +++ b/packages/server/src/models/ItemEntry.ts @@ -111,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: { @@ -204,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/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts index 2eae55b49..09fa9283b 100644 --- a/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts +++ b/packages/server/src/services/FinancialStatements/SalesTaxLiabilitySummary/SalesTaxLiabilitySummaryService.ts @@ -1,14 +1,21 @@ import { Inject, Service } from 'typedi'; import { SalesTaxLiabilitySummaryRepository } from './SalesTaxLiabilitySummaryRepository'; -import { SalesTaxLiabilitySummaryQuery } from '@/interfaces/SalesTaxLiabilitySummary'; +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 @@ -36,7 +43,7 @@ export class SalesTaxLiabilitySummaryService { return { data: taxLiabilitySummary.reportData(), query, - meta: {}, + meta: this.reportMetadata(tenantId), }; } @@ -60,6 +67,32 @@ export class SalesTaxLiabilitySummaryService { 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/Sales/Invoices/GetSaleInvoice.ts b/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts index b7eaa4440..f2245afef 100644 --- a/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts +++ b/packages/server/src/services/Sales/Invoices/GetSaleInvoice.ts @@ -32,6 +32,7 @@ export class GetSaleInvoice { const saleInvoice = await SaleInvoice.query() .findById(saleInvoiceId) .withGraphFetched('entries.item') + .withGraphFetched('entries.tax') .withGraphFetched('customer') .withGraphFetched('branch') .withGraphFetched('taxes.taxRate'); diff --git a/packages/server/src/services/TaxRates/GetTaxRate.ts b/packages/server/src/services/TaxRates/GetTaxRate.ts index 5dacd3d3f..5df27a87d 100644 --- a/packages/server/src/services/TaxRates/GetTaxRate.ts +++ b/packages/server/src/services/TaxRates/GetTaxRate.ts @@ -1,6 +1,8 @@ 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 { @@ -10,6 +12,9 @@ export class GetTaxRateService { @Inject() private validators: CommandTaxRatesValidators; + @Inject() + private transformer: TransformerInjectable; + /** * Retrieves the given tax rate. * @param {number} tenantId @@ -24,6 +29,11 @@ export class GetTaxRateService { // Validates the tax rate existance. this.validators.validateTaxRateExistance(taxRate); - return 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 index 8086dd121..c97b41340 100644 --- a/packages/server/src/services/TaxRates/GetTaxRates.ts +++ b/packages/server/src/services/TaxRates/GetTaxRates.ts @@ -1,11 +1,16 @@ 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 @@ -16,6 +21,11 @@ export class GetTaxRatesService { const taxRates = await TaxRate.query(); - return taxRates; + // Transforms the tax rates. + return this.transformer.transform( + tenantId, + taxRates, + new TaxRateTransformer() + ); } } diff --git a/packages/server/src/services/TaxRates/TaxRateTransformer.ts b/packages/server/src/services/TaxRates/TaxRateTransformer.ts new file mode 100644 index 000000000..25aed54f3 --- /dev/null +++ b/packages/server/src/services/TaxRates/TaxRateTransformer.ts @@ -0,0 +1,20 @@ +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']; + }; + + /** + * Formats the tax rate name. + * @param taxRate + * @returns {string} + */ + protected nameFormatted = (taxRate): string => { + return `${taxRate.name} (${taxRate.rate}%)`; + }; +} From b98b73ad9808c3a7598591c878442ae1911e1f31 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 11 Sep 2023 23:17:27 +0200 Subject: [PATCH 18/39] feat(webapp): invoice tax rate --- .../src/components/Forms/BlueprintFormik.tsx | 3 +- .../TaxRates/TaxRatesSuggestInputCell.tsx | 41 +++++ .../InvoiceDetailTableFooter.tsx | 16 +- .../Entries/ItemEntriesTableProvider.tsx | 20 +++ .../containers/Entries/ItemsEntriesTable.tsx | 103 ++++++------ .../src/containers/Entries/components.tsx | 16 +- .../webapp/src/containers/Entries/utils.tsx | 122 ++++++++++++-- .../InvoiceForm/InvoiceForm.schema.tsx | 5 + .../Invoices/InvoiceForm/InvoiceForm.tsx | 11 +- .../InvoiceForm/InvoiceFormActions.tsx | 79 ++++++++++ .../InvoiceForm/InvoiceFormFooterRight.tsx | 28 +++- .../InvoiceForm/InvoiceFormHeader.tsx | 4 +- .../InvoiceForm/InvoiceFormProvider.tsx | 7 + .../InvoiceItemsEntriesEditorField.tsx | 53 +++---- .../Sales/Invoices/InvoiceForm/constants.ts | 6 + .../Sales/Invoices/InvoiceForm/utils.tsx | 149 ++++++++++++++++-- packages/webapp/src/hooks/query/taxRates.ts | 22 +++ packages/webapp/src/hooks/query/types.tsx | 5 + packages/webapp/src/hooks/useUncontrolled.ts | 36 +++++ packages/webapp/src/interfaces/ItemEntries.ts | 11 ++ packages/webapp/src/interfaces/TaxRates.ts | 4 + 21 files changed, 615 insertions(+), 126 deletions(-) create mode 100644 packages/webapp/src/components/TaxRates/TaxRatesSuggestInputCell.tsx create mode 100644 packages/webapp/src/containers/Entries/ItemEntriesTableProvider.tsx create mode 100644 packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormActions.tsx create mode 100644 packages/webapp/src/containers/Sales/Invoices/InvoiceForm/constants.ts create mode 100644 packages/webapp/src/hooks/query/taxRates.ts create mode 100644 packages/webapp/src/hooks/useUncontrolled.ts create mode 100644 packages/webapp/src/interfaces/ItemEntries.ts create mode 100644 packages/webapp/src/interfaces/TaxRates.ts 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..e6fed6bb4 --- /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'} + popoverProps={{ minimal: true, boundary: 'window' }} + inputProps={{ placeholder: '' }} + fill={true} + onItemChange={handleItemSelected} + {...suggestProps} + /> + + ); +} + +TaxRatesSuggestInputCell.cellType = CellType.Field; diff --git a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailTableFooter.tsx b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailTableFooter.tsx index 69f512145..c9a9fe704 100644 --- a/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailTableFooter.tsx +++ b/packages/webapp/src/containers/Drawers/InvoiceDetailDrawer/InvoiceDetailTableFooter.tsx @@ -23,22 +23,30 @@ export function InvoiceDetailTableFooter() { } - value={} + value={} borderStyle={TotalLineBorderStyle.SingleDark} /> + {invoice.taxes.map((taxRate) => ( + + ))} } - value={invoice.formatted_amount} + value={invoice.total_formatted} borderStyle={TotalLineBorderStyle.DoubleDark} textStyle={TotalLineTextStyle.Bold} /> } - value={invoice.formatted_payment_amount} + value={invoice.payment_amount_formatted} /> } - value={invoice.formatted_due_amount} + value={invoice.due_amount_formatted} textStyle={TotalLineTextStyle.Bold} /> diff --git a/packages/webapp/src/containers/Entries/ItemEntriesTableProvider.tsx b/packages/webapp/src/containers/Entries/ItemEntriesTableProvider.tsx new file mode 100644 index 000000000..9d9c88edd --- /dev/null +++ b/packages/webapp/src/containers/Entries/ItemEntriesTableProvider.tsx @@ -0,0 +1,20 @@ +// @ts-nocheck +import React, { createContext } from 'react'; + +const ItemEntriesTableContext = createContext(); + +function ItemEntriesTableProvider({ children, value }) { + const provider = { + ...value, + }; + return ( + + {children} + + ); +} + +const useItemEntriesTableContext = () => + React.useContext(ItemEntriesTableContext); + +export { ItemEntriesTableProvider, useItemEntriesTableContext }; diff --git a/packages/webapp/src/containers/Entries/ItemsEntriesTable.tsx b/packages/webapp/src/containers/Entries/ItemsEntriesTable.tsx index 6b51f8566..4a881cfec 100644 --- a/packages/webapp/src/containers/Entries/ItemsEntriesTable.tsx +++ b/packages/webapp/src/containers/Entries/ItemsEntriesTable.tsx @@ -1,103 +1,104 @@ // @ts-nocheck -import React, { useEffect, useCallback } from 'react'; +import React, { useCallback } from 'react'; import classNames from 'classnames'; import { CLASSES } from '@/constants/classes'; import { DataTableEditable } from '@/components'; import { useEditableItemsEntriesColumns } from './components'; -import { - saveInvoke, - compose, - updateMinEntriesLines, - updateRemoveLineByIndex, -} from '@/utils'; import { useFetchItemRow, composeRowsOnNewRow, - composeRowsOnEditCell, + useComposeRowsOnEditTableCell, + useComposeRowsOnRemoveTableRow, } from './utils'; +import { + ItemEntriesTableProvider, + useItemEntriesTableContext, +} from './ItemEntriesTableProvider'; +import { useUncontrolled } from '@/hooks/useUncontrolled'; /** * Items entries table. */ -function ItemsEntriesTable({ - // #ownProps - items, - entries, - initialEntries, - defaultEntry, - errors, - onUpdateData, - currencyCode, - itemType, // sellable or purchasable - landedCost = false, - minLinesNumber -}) { - const [rows, setRows] = React.useState(initialEntries); +function ItemsEntriesTable(props) { + const { value, initialValue, onChange } = props; - // Allows to observes `entries` to make table rows outside controlled. - useEffect(() => { - if (entries && entries !== rows) { - setRows(entries); - } - }, [entries, rows]); + const [localValue, handleChange] = useUncontrolled({ + value, + initialValue, + finalValue: [], + onChange, + }); + return ( + + + + ); +} + +/** + * Items entries table logic. + * @returns {JSX.Element} + */ +function ItemEntriesTableRoot() { + const { + localValue, + defaultEntry, + handleChange, + items, + errors, + currencyCode, + landedCost, + taxRates, + } = useItemEntriesTableContext(); // Editiable items entries columns. - const columns = useEditableItemsEntriesColumns({ landedCost }); + const columns = useEditableItemsEntriesColumns(); + + const composeRowsOnEditCell = useComposeRowsOnEditTableCell(); + const composeRowsOnDeleteRow = useComposeRowsOnRemoveTableRow(); // Handle the fetch item row details. const { setItemRow, cellsLoading, isItemFetching } = useFetchItemRow({ landedCost, - itemType, + itemType: null, notifyNewRow: (newRow, rowIndex) => { // Update the rate, description and quantity data of the row. - const newRows = composeRowsOnNewRow(rowIndex, newRow, rows); - - setRows(newRows); - onUpdateData(newRows); + const newRows = composeRowsOnNewRow(rowIndex, newRow, localValue); + handleChange(newRows); }, }); - // Handles the editor data update. const handleUpdateData = useCallback( (rowIndex, columnId, value) => { if (columnId === 'item_id') { setItemRow({ rowIndex, columnId, itemId: value }); } - const composeEditCell = composeRowsOnEditCell(rowIndex, columnId); - const newRows = composeEditCell(value, defaultEntry, rows); - - setRows(newRows); - onUpdateData(newRows); + const newRows = composeRowsOnEditCell(rowIndex, columnId, value); + handleChange(newRows); }, - [rows, defaultEntry, onUpdateData, setItemRow], + [localValue, defaultEntry, handleChange], ); // Handle table rows removing by index. const handleRemoveRow = (rowIndex) => { - const newRows = compose( - // Ensure minimum lines count. - updateMinEntriesLines(minLinesNumber, defaultEntry), - // Remove the line by the given index. - updateRemoveLineByIndex(rowIndex), - )(rows); - - setRows(newRows); - saveInvoke(onUpdateData, newRows); + const newRows = composeRowsOnDeleteRow(rowIndex); + handleChange(newRows); }; return ( { removeRow(index); }; - const exampleMenu = ( { /** * Retrieve editable items entries columns. */ -export function useEditableItemsEntriesColumns({ landedCost }) { +export function useEditableItemsEntriesColumns() { const { featureCan } = useFeatureCan(); + const { landedCost } = useItemEntriesTableContext(); + const isProjectsFeatureEnabled = featureCan(Features.Projects); return React.useMemo( () => [ { - Header: ItemHeaderCell, id: 'item_id', + Header: ItemHeaderCell, accessor: 'item_id', Cell: ItemsListCell, disableSortBy: true, @@ -129,6 +132,13 @@ export function useEditableItemsEntriesColumns({ landedCost }) { width: 70, align: Align.Right, }, + { + Header: 'Tax rate', + accessor: 'tax_rate_id', + Cell: TaxRatesSuggestInputCell, + disableSortBy: true, + width: 110, + }, { Header: intl.get('discount'), accessor: 'discount', diff --git a/packages/webapp/src/containers/Entries/utils.tsx b/packages/webapp/src/containers/Entries/utils.tsx index 6c8f8b01e..51ff04412 100644 --- a/packages/webapp/src/containers/Entries/utils.tsx +++ b/packages/webapp/src/containers/Entries/utils.tsx @@ -1,7 +1,7 @@ // @ts-nocheck -import React from 'react'; +import React, { useCallback } from 'react'; import * as R from 'ramda'; -import { sumBy, isEmpty, last } from 'lodash'; +import { sumBy, isEmpty, last, keyBy } from 'lodash'; import { useItem } from '@/hooks/query'; import { @@ -13,6 +13,12 @@ import { orderingLinesIndexes, updateTableRow, } from '@/utils'; +import { useItemEntriesTableContext } from './ItemEntriesTableProvider'; + +export const ITEM_TYPE = { + SELLABLE: 'SELLABLE', + PURCHASABLE: 'PURCHASABLE', +}; /** * Retrieve item entry total from the given rate, quantity and discount. @@ -39,11 +45,6 @@ export function updateItemsEntriesTotal(rows) { })); } -export const ITEM_TYPE = { - SELLABLE: 'SELLABLE', - PURCHASABLE: 'PURCHASABLE', -}; - /** * Retrieve total of the given items entries. */ @@ -150,12 +151,7 @@ export function useFetchItemRow({ landedCost, itemType, notifyNewRow }) { */ export const composeRowsOnEditCell = R.curry( (rowIndex, columnId, value, defaultEntry, rows) => { - return compose( - orderingLinesIndexes, - updateAutoAddNewLine(defaultEntry, ['item_id']), - updateItemsEntriesTotal, - updateTableCell(rowIndex, columnId, value), - )(rows); + return compose()(rows); }, ); @@ -171,10 +167,102 @@ export const composeRowsOnNewRow = R.curry((rowIndex, newRow, rows) => { }); /** - * - * @param {*} entries + * Associate tax rate to entries. + */ +export const assignEntriesTaxRate = R.curry((taxRates, entries) => { + const taxRatesById = keyBy(taxRates, 'id'); + + return entries.map((entry) => { + const taxRate = taxRatesById[entry.tax_rate_id]; + + return { + ...entry, + tax_rate: taxRate?.rate || 0, + }; + }); +}); + +/** + * Assign tax amount to entries. + * @param {boolean} isInclusiveTax + * @param entries * @returns */ -export const composeControlledEntries = (entries) => { - return R.compose(orderingLinesIndexes, updateItemsEntriesTotal)(entries); +export const assignEntriesTaxAmount = R.curry( + (isInclusiveTax: boolean, entries) => { + return entries.map((entry) => { + const taxAmount = isInclusiveTax + ? getInclusiveTaxAmount(entry.amount, entry.tax_rate) + : getExlusiveTaxAmount(entry.amount, entry.tax_rate); + + return { + ...entry, + tax_amount: taxAmount, + }; + }); + }, +); + +/** + * Get inclusive tax amount. + * @param {number} amount + * @param {number} taxRate + * @returns {number} + */ +export const getInclusiveTaxAmount = (amount: number, taxRate: number) => { + return (amount * taxRate) / (100 + taxRate); +}; + +/** + * Get exclusive tax amount. + * @param {number} amount + * @param {number} taxRate + * @returns {number} + */ +export const getExlusiveTaxAmount = (amount: number, taxRate: number) => { + return (amount * taxRate) / 100; +}; + +/** + * Compose rows when edit a table cell. + * @returns {Function} + */ +export const useComposeRowsOnEditTableCell = () => { + const { taxRates, isInclusiveTax, localValue, defaultEntry } = + useItemEntriesTableContext(); + + return useCallback( + (rowIndex, columnId, value) => { + return R.compose( + assignEntriesTaxAmount(isInclusiveTax), + assignEntriesTaxRate(taxRates), + orderingLinesIndexes, + updateAutoAddNewLine(defaultEntry, ['item_id']), + updateItemsEntriesTotal, + updateTableCell(rowIndex, columnId, value), + )(localValue); + }, + [taxRates, isInclusiveTax, localValue, defaultEntry], + ); +}; + +/** + * Compose rows when remove a table row. + * @returns {Function} + */ +export const useComposeRowsOnRemoveTableRow = () => { + const { minLinesNumber, defaultEntry, localValue } = + useItemEntriesTableContext(); + + return useCallback( + (rowIndex) => { + return compose( + // Ensure minimum lines count. + updateMinEntriesLines(minLinesNumber, defaultEntry), + // Remove the line by the given index. + updateRemoveLineByIndex(rowIndex), + )(localValue); + }, + [minLinesNumber, defaultEntry, localValue], + ); }; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.schema.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.schema.tsx index c247b7b5a..66d35fa30 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.schema.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.schema.tsx @@ -4,6 +4,7 @@ import moment from 'moment'; import intl from 'react-intl-universal'; import { DATATYPES_LENGTH } from '@/constants/dataTypes'; import { isBlank } from '@/utils'; +import { TaxType } from '@/interfaces/TaxRates'; const getSchema = () => Yup.object().shape({ @@ -35,6 +36,10 @@ const getSchema = () => .max(DATATYPES_LENGTH.TEXT) .label(intl.get('note')), exchange_rate: Yup.number(), + inclusive_exclusive_tax: Yup.string().oneOf([ + TaxType.Inclusive, + TaxType.Exclusive, + ]), branch_id: Yup.string(), warehouse_id: Yup.string(), project_id: Yup.string(), diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx index 983d1d001..a8463619b 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceForm.tsx @@ -1,5 +1,5 @@ // @ts-nocheck -import React, { useMemo } from 'react'; +import React from 'react'; import intl from 'react-intl-universal'; import classNames from 'classnames'; import { Formik, Form } from 'formik'; @@ -26,6 +26,7 @@ import withCurrentOrganization from '@/containers/Organization/withCurrentOrgani import { AppToaster } from '@/components'; import { compose, orderingLinesIndexes, transactionNumber } from '@/utils'; import { useInvoiceFormContext } from './InvoiceFormProvider'; +import { InvoiceFormActions } from './InvoiceFormActions'; import { transformToEditForm, defaultInvoice, @@ -71,7 +72,7 @@ function InvoiceForm({ ? { ...transformToEditForm(invoice) } : { ...defaultInvoice, - // If the auto-increment mode is enabled, take the next invoice + // If the auto-increment mode is enabled, take the next invoice // number from the settings. ...(invoiceAutoIncrementMode && { invoice_no: invoiceNumber, @@ -166,7 +167,11 @@ function InvoiceForm({
- + +
+ + +
diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormActions.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormActions.tsx new file mode 100644 index 000000000..e185455f9 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormActions.tsx @@ -0,0 +1,79 @@ +// @ts-nocheck +import React from 'react'; +import styled from 'styled-components'; +import { useFormikContext } from 'formik'; +import { InclusiveButtonOptions } from './constants'; +import { Box, FFormGroup, FSelect } from '@/components'; +import { composeEntriesOnEditInclusiveTax } from './utils'; + +/** + * Invoice form actions. + * @returns {React.ReactNode} + */ +export function InvoiceFormActions() { + return ( + + + + ); +} + +/** + * Invoice exclusive/inclusive select. + * @returns {React.ReactNode} + */ +export function InvoiceExclusiveInclusiveSelect(props) { + const { values, setFieldValue } = useFormikContext(); + + const handleItemSelect = (item) => { + const newEntries = composeEntriesOnEditInclusiveTax( + item.key, + values.entries, + ); + setFieldValue('inclusive_exclusive_tax', item.key); + setFieldValue('entries', newEntries); + }; + + return ( + + ''} + valueAccessor={'key'} + popoverProps={{ minimal: true, usePortal: true, inline: false }} + buttonProps={{ small: true }} + onItemSelect={handleItemSelect} + filterable={false} + {...props} + /> + + ); +} + +const InclusiveFormGroup = styled(FFormGroup)` + margin-bottom: 0; + margin-left: auto; + + &.bp3-form-group.bp3-inline label.bp3-label { + line-height: 1.25; + opacity: 0.6; + margin-right: 8px; + } +`; + +const InclusiveSelect = styled(FSelect)` + .bp3-button { + padding-right: 24px; + } +`; + +const InvoiceFormActionsRoot = styled(Box)` + padding-bottom: 12px; + display: flex; +`; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormFooterRight.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormFooterRight.tsx index 4a9c6cfe4..c9ba4b687 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormFooterRight.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormFooterRight.tsx @@ -1,6 +1,7 @@ // @ts-nocheck import React from 'react'; import styled from 'styled-components'; +import { useFormikContext } from 'formik'; import { T, @@ -9,7 +10,7 @@ import { TotalLineBorderStyle, TotalLineTextStyle, } from '@/components'; -import { useInvoiceTotals } from './utils'; +import { useInvoiceAggregatedTaxRates, useInvoiceTotals } from './utils'; export function InvoiceFormFooterRight() { // Calculate the total due amount of invoice entries. @@ -20,15 +21,34 @@ export function InvoiceFormFooterRight() { formattedPaymentTotal, } = useInvoiceTotals(); + const { + values: { inclusive_exclusive_tax }, + } = useFormikContext(); + + const taxEntries = useInvoiceAggregatedTaxRates(); + return ( } + title={ + <> + {inclusive_exclusive_tax === 'inclusive' + ? 'Subtotal (Tax Inclusive)' + : 'Subtotal'} + + } value={formattedSubtotal} - borderStyle={TotalLineBorderStyle.None} /> + {taxEntries.map((tax, index) => ( + + ))} } + title={'Total (USD)'} value={formattedTotal} borderStyle={TotalLineBorderStyle.SingleDark} textStyle={TotalLineTextStyle.Bold} diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeader.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeader.tsx index 82f57a266..66d1ed48c 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeader.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/InvoiceFormHeader.tsx @@ -8,7 +8,7 @@ import InvoiceFormHeaderFields from './InvoiceFormHeaderFields'; import { CLASSES } from '@/constants/classes'; import { PageFormBigNumber } from '@/components'; -import { useInvoiceTotal } from './utils'; +import { useInvoiceSubtotal } from './utils'; /** * Invoice form header section. @@ -32,7 +32,7 @@ function InvoiceFormBigTotal() { } = useFormikContext(); // Calculate the total due amount of invoice entries. - const totalDueAmount = useInvoiceTotal(); + const totalDueAmount = useInvoiceSubtotal(); return ( - - {({ - form: { values, setFieldValue }, - field: { value }, - meta: { error, touched }, - }) => ( - { - setFieldValue('entries', entries); - }} - items={items} - errors={error} - linesNumber={4} - currencyCode={values.currency_code} - /> - )} - - + + {({ + form: { values, setFieldValue }, + field: { value }, + meta: { error, touched }, + }) => ( + { + setFieldValue('entries', entries); + }} + items={items} + taxRates={taxRates} + errors={error} + linesNumber={4} + currencyCode={values.currency_code} + isInclusiveTax={values.inclusive_exclusive_tax === 'inclusive'} + /> + )} + ); } diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/constants.ts b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/constants.ts new file mode 100644 index 000000000..5b35f08a3 --- /dev/null +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/constants.ts @@ -0,0 +1,6 @@ + + +export const InclusiveButtonOptions = [ + { key: 'inclusive', label: 'Inclusive of Tax' }, + { key: 'exclusive', label: 'Exclusive of Tax' }, +]; diff --git a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx index 4af9dabb9..66effa014 100644 --- a/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx +++ b/packages/webapp/src/containers/Sales/Invoices/InvoiceForm/utils.tsx @@ -1,27 +1,27 @@ // @ts-nocheck -import React from 'react'; +import React, { useCallback, useMemo } from 'react'; import intl from 'react-intl-universal'; import moment from 'moment'; +import * as R from 'ramda'; import { Intent } from '@blueprintjs/core'; -import { omit, first } from 'lodash'; -import { - compose, - transformToForm, - repeatValue, - transactionNumber, -} from '@/utils'; +import { omit, first, keyBy, sumBy, groupBy } from 'lodash'; +import { compose, transformToForm, repeatValue } from '@/utils'; import { useFormikContext } from 'formik'; import { formattedAmount, defaultFastFieldShouldUpdate } from '@/utils'; import { ERROR } from '@/constants/errors'; import { AppToaster } from '@/components'; import { useCurrentOrganization } from '@/hooks/state'; -import { getEntriesTotal } from '@/containers/Entries/utils'; +import { + assignEntriesTaxAmount, + getEntriesTotal, +} from '@/containers/Entries/utils'; import { useInvoiceFormContext } from './InvoiceFormProvider'; import { updateItemsEntriesTotal, ensureEntriesHaveEmptyLine, } from '@/containers/Entries/utils'; +import { TaxType } from '@/interfaces/TaxRates'; export const MIN_LINES_NUMBER = 1; @@ -34,6 +34,9 @@ export const defaultInvoiceEntry = { quantity: '', description: '', amount: '', + tax_rate_id: '', + tax_rate: '', + tax_amount: '', }; // Default invoice object. @@ -43,6 +46,7 @@ export const defaultInvoice = { due_date: moment().format('YYYY-MM-DD'), delivered: '', invoice_no: '', + inclusive_exclusive_tax: 'inclusive', // Holds the invoice number that entered manually only. invoice_no_manually: '', reference_no: '', @@ -114,7 +118,7 @@ export const transformErrors = (errors, { setErrors }) => { */ export const customerNameFieldShouldUpdate = (newProps, oldProps) => { return ( - newProps.shouldUpdateDeps.items !== oldProps.shouldUpdateDeps.items|| + newProps.shouldUpdateDeps.items !== oldProps.shouldUpdateDeps.items || defaultFastFieldShouldUpdate(newProps, oldProps) ); }; @@ -125,6 +129,7 @@ export const customerNameFieldShouldUpdate = (newProps, oldProps) => { export const entriesFieldShouldUpdate = (newProps, oldProps) => { return ( newProps.items !== oldProps.items || + newProps.taxRates !== oldProps.taxRates || defaultFastFieldShouldUpdate(newProps, oldProps) ); }; @@ -154,12 +159,17 @@ export function transformValueToRequest(values) { (item) => item.item_id && item.quantity, ); return { - ...omit(values, ['invoice_no', 'invoice_no_manually']), + ...omit(values, [ + 'invoice_no', + 'invoice_no_manually', + 'inclusive_exclusive_tax', + ]), // The `invoice_no_manually` will be presented just if the auto-increment // is disable, always both attributes hold the same value in manual mode. ...(values.invoice_no_manually && { invoice_no: values.invoice_no, }), + is_inclusive_tax: values.inclusive_exclusive_tax === 'inclusive', entries: entries.map((entry) => ({ ...omit(entry, ['amount']) })), delivered: false, }; @@ -196,7 +206,11 @@ export const useSetPrimaryBranchToForm = () => { }, [isBranchesSuccess, setFieldValue, branches]); }; -export const useInvoiceTotal = () => { +/** + * Retrieves the invoice subtotal. + * @returns {number} + */ +export const useInvoiceSubtotal = () => { const { values: { entries }, } = useFormikContext(); @@ -216,10 +230,12 @@ export const useInvoiceTotals = () => { // Retrieves the invoice entries total. const total = React.useMemo(() => getEntriesTotal(entries), [entries]); + const total_ = useInvoiceTotal(); + // Retrieves the formatted total money. const formattedTotal = React.useMemo( - () => formattedAmount(total, currencyCode), - [total, currencyCode], + () => formattedAmount(total_, currencyCode), + [total_, currencyCode], ); // Retrieves the formatted subtotal. const formattedSubtotal = React.useMemo( @@ -271,6 +287,9 @@ export const useInvoiceIsForeignCustomer = () => { return isForeignCustomer; }; +/** + * Resets the form state to initial values + */ export const resetFormState = ({ initialValues, values, resetForm }) => { resetForm({ values: { @@ -281,3 +300,105 @@ export const resetFormState = ({ initialValues, values, resetForm }) => { }, }); }; + +/** + * Re-calcualte the entries tax amount when editing. + * @returns {string} + */ +export const composeEntriesOnEditInclusiveTax = ( + inclusiveExclusiveTax: string, + entries, +) => { + return R.compose( + assignEntriesTaxAmount(inclusiveExclusiveTax === 'inclusive'), + )(entries); +}; + +/** + * Retreives the invoice aggregated tax rates. + * @returns {Array} + */ +export const useInvoiceAggregatedTaxRates = () => { + const { values } = useFormikContext(); + const { taxRates } = useInvoiceFormContext(); + + const taxRatesById = useMemo(() => keyBy(taxRates, 'id'), [taxRates]); + + // Calculate the total tax amount of invoice entries. + return React.useMemo(() => { + const filteredEntries = values.entries.filter((e) => e.tax_rate_id); + const groupedTaxRates = groupBy(filteredEntries, 'tax_rate_id'); + + return Object.keys(groupedTaxRates).map((taxRateId) => { + const taxRate = taxRatesById[taxRateId]; + const taxRates = groupedTaxRates[taxRateId]; + const totalTaxAmount = sumBy(taxRates, 'tax_amount'); + const taxAmountFormatted = formattedAmount(totalTaxAmount, 'USD'); + + return { + taxRateId, + taxRate: taxRate.rate, + label: `${taxRate.name} [${taxRate.rate}%]`, + taxAmount: totalTaxAmount, + taxAmountFormatted, + }; + }); + }, [values.entries]); +}; + +/** + * Retreives the invoice total tax amount. + * @returns {number} + */ +export const useInvoiceTotalTaxAmount = () => { + const { values } = useFormikContext(); + + return React.useMemo(() => { + const filteredEntries = values.entries.filter((entry) => entry.tax_amount); + return sumBy(filteredEntries, 'tax_amount'); + }, [values.entries]); +}; + +/** + * Retreives the invoice total. + * @returns {number} + */ +export const useInvoiceTotal = () => { + const subtotal = useInvoiceSubtotal(); + const totalTaxAmount = useInvoiceTotalTaxAmount(); + const isExclusiveTax = useIsInvoiceTaxExclusive(); + + return R.compose(R.when(R.always(isExclusiveTax), R.add(totalTaxAmount)))( + subtotal, + ); +}; + +/** + * Retreives the invoice due amount. + * @returns {number} + */ +export const useInvoiceDueAmount = () => { + const total = useInvoiceTotal(); + + return total; +}; + +/** + * Detrmines whether the tax is inclusive. + * @returns {boolean} + */ +export const useIsInvoiceTaxInclusive = () => { + const { values } = useFormikContext(); + + return values.inclusive_exclusive_tax === TaxType.Inclusive; +}; + +/** + * Detrmines whether the tax is exclusive. + * @returns {boolean} + */ +export const useIsInvoiceTaxExclusive = () => { + const { values } = useFormikContext(); + + return values.inclusive_exclusive_tax === TaxType.Exclusive; +}; diff --git a/packages/webapp/src/hooks/query/taxRates.ts b/packages/webapp/src/hooks/query/taxRates.ts new file mode 100644 index 000000000..f43a15078 --- /dev/null +++ b/packages/webapp/src/hooks/query/taxRates.ts @@ -0,0 +1,22 @@ +// @ts-nocheck +import { useRequestQuery } from '../useQueryRequest'; +import QUERY_TYPES from './types'; + +/** + * 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, + }, + ); +} diff --git a/packages/webapp/src/hooks/query/types.tsx b/packages/webapp/src/hooks/query/types.tsx index c557228db..67ddd0761 100644 --- a/packages/webapp/src/hooks/query/types.tsx +++ b/packages/webapp/src/hooks/query/types.tsx @@ -224,6 +224,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 +261,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', +} From 8a641984339053eb06ae18de985c44fa92c72369 Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Thu, 14 Sep 2023 23:35:54 +0200 Subject: [PATCH 19/39] feat(webapp): wip tax rates management --- .../src/components/DialogsContainer.tsx | 6 +- .../src/components/DrawersContainer.tsx | 18 ++- .../webapp/src/constants/abilityOption.tsx | 11 +- packages/webapp/src/constants/dialogs.ts | 3 +- packages/webapp/src/constants/drawers.ts | 1 + packages/webapp/src/constants/sidebarMenu.tsx | 5 + .../containers/AlertsContainer/registered.tsx | 2 + .../InvoiceDetailHeader.tsx | 8 +- .../Sales/Invoices/InvoiceForm/utils.tsx | 3 + .../Invoices/InvoicesLanding/components.tsx | 4 +- .../TaxRates/alerts/TaxRateDeleteAlert.tsx | 97 ++++++++++++++ .../src/containers/TaxRates/alerts/index.ts | 11 ++ .../containers/TaxRatesLandingActionsBar.tsx | 61 +++++++++ .../containers/TaxRatesLandingEmptyState.tsx | 39 ++++++ .../containers/TaxRatesLandingProvider.tsx | 41 ++++++ .../containers/TaxRatesLandingTable.tsx | 110 ++++++++++++++++ .../TaxRates/containers/_components.tsx | 42 ++++++ .../containers/TaxRates/containers/_utils.tsx | 53 ++++++++ .../TaxRateFormDialog/TaxRateForm.schema.ts | 16 +++ .../TaxRateFormDialog/TaxRateFormDialog.tsx | 39 ++++++ .../TaxRateFormDialogBoot.tsx | 37 ++++++ .../TaxRateFormDialogContent.tsx | 15 +++ .../TaxRateFormDialogForm.tsx | 124 ++++++++++++++++++ .../TaxRateFormDialogFormContent.tsx | 77 +++++++++++ .../TaxRateFormDialogFormFooter.tsx | 41 ++++++ .../dialogs/TaxRateFormDialog/utils.ts | 7 + .../TaxRateDetailsContent.tsx | 29 ++++ .../TaxRateDetailsContentActionsBar.tsx | 71 ++++++++++ .../TaxRateDetailsContentBoot.tsx | 40 ++++++ .../TaxRateDetailsContentDetails.tsx | 68 ++++++++++ .../TaxRateDetailsDrawer.tsx | 35 +++++ .../TaxRates/pages/TaxRatesLanding.tsx | 23 ++++ packages/webapp/src/hooks/query/taxRates.ts | 74 +++++++++++ packages/webapp/src/routes/dashboard.tsx | 8 ++ 34 files changed, 1205 insertions(+), 14 deletions(-) create mode 100644 packages/webapp/src/containers/TaxRates/alerts/TaxRateDeleteAlert.tsx create mode 100644 packages/webapp/src/containers/TaxRates/alerts/index.ts create mode 100644 packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingActionsBar.tsx create mode 100644 packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingEmptyState.tsx create mode 100644 packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingProvider.tsx create mode 100644 packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingTable.tsx create mode 100644 packages/webapp/src/containers/TaxRates/containers/_components.tsx create mode 100644 packages/webapp/src/containers/TaxRates/containers/_utils.tsx create mode 100644 packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateForm.schema.ts create mode 100644 packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialog.tsx create mode 100644 packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogBoot.tsx create mode 100644 packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogContent.tsx create mode 100644 packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogForm.tsx create mode 100644 packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogFormContent.tsx create mode 100644 packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogFormFooter.tsx create mode 100644 packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/utils.ts create mode 100644 packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContent.tsx create mode 100644 packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContentActionsBar.tsx create mode 100644 packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContentBoot.tsx create mode 100644 packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContentDetails.tsx create mode 100644 packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsDrawer.tsx create mode 100644 packages/webapp/src/containers/TaxRates/pages/TaxRatesLanding.tsx 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/constants/abilityOption.tsx b/packages/webapp/src/constants/abilityOption.tsx index 85168e352..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 = { @@ -186,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/sidebarMenu.tsx b/packages/webapp/src/constants/sidebarMenu.tsx index 53189dd1f..108ed860b 100644 --- a/packages/webapp/src/constants/sidebarMenu.tsx +++ b/packages/webapp/src/constants/sidebarMenu.tsx @@ -406,6 +406,11 @@ export const SidebarMenu = [ href: '/transactions-locking', type: ISidebarMenuItemType.Link, }, + { + text: 'Tax Rates', + href: '/tax-rates', + type: ISidebarMenuItemType.Link, + }, ], }, { 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} { + closeAlert(name); + }; + + // Handle confirm delete item. + const handleConfirmDeleteItem = () => { + deleteTaxRate(taxRateId) + .then(() => { + AppToaster.show({ + message: 'The tax rate has been deleted successfully.', + intent: Intent.SUCCESS, + }); + closeDrawer(DRAWERS.TAX_RATE_DETAILS); + }) + .catch( + ({ + response: { + data: { errors }, + }, + }) => { + // handleDeleteErrors(errors); + }, + ) + .finally(() => { + closeAlert(name); + }); + }; + + return ( + } + confirmButtonText={} + icon="trash" + intent={Intent.DANGER} + isOpen={isOpen} + onCancel={handleCancelItemDelete} + onConfirm={handleConfirmDeleteItem} + loading={isLoading} + > +

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

+ +

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

+
+ ); +} + +export default compose( + withAlertStoreConnect(), + withAlertActions, + withItemsActions, + withDrawerActions, +)(TaxRateDeleteAlert); diff --git a/packages/webapp/src/containers/TaxRates/alerts/index.ts b/packages/webapp/src/containers/TaxRates/alerts/index.ts new file mode 100644 index 000000000..31680d125 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/alerts/index.ts @@ -0,0 +1,11 @@ +// @ts-nocheck +import React from 'react'; + +const TaxRateDeleteAlert = React.lazy(() => import('./TaxRateDeleteAlert')); + +/** + * Project alerts. + */ +export default [ + { name: 'tax-rate-delete', component: TaxRateDeleteAlert }, +]; diff --git a/packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingActionsBar.tsx b/packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingActionsBar.tsx new file mode 100644 index 000000000..2f370e0ce --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingActionsBar.tsx @@ -0,0 +1,61 @@ +// @ts-nocheck +import React from 'react'; +import { NavbarGroup, NavbarDivider, Button, Classes } from '@blueprintjs/core'; +import { + DashboardActionsBar, + FormattedMessage as T, + Can, + Icon, +} from '@/components'; +import { AbilitySubject, TaxRateAction } from '@/constants/abilityOption'; +import { useTaxRatesLandingContext } from './TaxRatesLandingProvider'; + +import withDialogActions from '@/containers/Dialog/withDialogActions'; + +import { DialogsName } from '@/constants/dialogs'; +import { compose } from '@/utils'; + +/** + * Tax rates actions bar. + */ +function TaxRatesActionsBar({ + // #withDialogActions + openDialog, +}) { + // Items list context. + const {} = useTaxRatesLandingContext(); + + // Handle `new item` button click. + const onClickNewItem = () => { + openDialog(DialogsName.TaxRateForm); + }; + + return ( + + + + + + + + } + /> + ); +} 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..e49b1b3f3 --- /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..c20ad9bc7 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/containers/TaxRatesLandingTable.tsx @@ -0,0 +1,110 @@ +// @ts-nocheck +import React, { useCallback } from 'react'; +import { useHistory } from 'react-router-dom'; +import { + DataTable, + DashboardContentTable, + TableSkeletonHeader, + TableSkeletonRows, +} 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 { useMemorizedColumnsWidths } from '@/hooks'; +// import { ActionsMenu } from './components'; +// import { useInvoicesListContext } from './InvoicesListProvider'; + +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'; + +/** + * Invoices datatable. + */ +function TaxRatesDataTable({ + // #withAlertsActions + openAlert, + + // #withDrawerActions + openDrawer, + + // #withDialogAction + openDialog, +}) { + // Invoices list context. + const { taxRates, isTaxRatesLoading, isEmptyStatus } = + useTaxRatesLandingContext(); + + // Invoices table columns. + const columns = useTaxRatesTableColumns(); + + // 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 }); + }; + // 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..5c3e50a34 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/containers/_components.tsx @@ -0,0 +1,42 @@ +// @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 }, + row: { original }, +}) { + return ( + + } + text={'View Details'} + onClick={safeCallback(onViewDetails, original)} + /> + + + } + text={'Edit Tax Rate'} + onClick={safeCallback(onEdit, original)} + /> + + + + } + /> + + + ); +} diff --git a/packages/webapp/src/containers/TaxRates/containers/_utils.tsx b/packages/webapp/src/containers/TaxRates/containers/_utils.tsx new file mode 100644 index 000000000..2eb2fbb21 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/containers/_utils.tsx @@ -0,0 +1,53 @@ +// @ts-nocheck +import React from 'react'; +import { Button, Intent, Tag, Icon } from '@blueprintjs/core'; +import { Align } from '@/constants'; +import { FormatDateCell } from '@/components'; + +const codeAccessor = (taxRate) => { + return ( + + {taxRate.code} + + ); +}; + +const statusAccessor = (taxRate) => { + return ( + + Active + + ); +}; + +export const useTaxRatesTableColumns = () => { + return [ + { + Header: 'Name', + accessor: 'name', + width: 40, + }, + { + Header: 'Code', + accessor: codeAccessor, + width: 40, + }, + { + Header: 'Rate', + accessor: 'rate_formatted', + align: Align.Right, + width: 30, + }, + { + Header: 'Description', + accessor: () => Specital tax for certain goods and services., + width: 120, + }, + { + Header: 'Status', + accessor: statusAccessor, + width: 30, + align: Align.Right, + }, + ]; +}; 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..929197460 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateForm.schema.ts @@ -0,0 +1,16 @@ +// @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().required().label('Rate'), + is_compound: Yup.boolean().optional().label('Is Compound'), + is_non_recoverable: Yup.boolean().optional().label('Is Non Recoverable'), + }); + +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..4a48c4f16 --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialog.tsx @@ -0,0 +1,39 @@ +// @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'), +); + +const TaxRateDialog = styled(Dialog)` + max-width: 450px; +`; + +/** + * Account form dialog. + */ +function TaxRateFormDialog({ + dialogName, + payload = { action: '', id: null }, + isOpen, +}) { + return ( + + + + + + ); +} + +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..5fc9dbbfe --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogBoot.tsx @@ -0,0 +1,37 @@ +// @ts-nocheck +import React, { useState } from 'react'; +import { DialogContent } from '@/components'; +import { useTaxRates } from '@/hooks/query/taxRates'; + +const TaxRateFormDialogContext = React.createContext(); + +/** + * Money in dialog provider. + */ +function TaxRateFormDialogBoot({ ...props }) { + const { + data: taxRates, + isLoading: isTaxRatesLoading, + isSuccess: isTaxRatesSuccess, + } = useTaxRates({}); + + // Provider data. + const provider = { + taxRates, + isTaxRatesLoading, + isTaxRatesSuccess, + }; + + const isLoading = isTaxRatesLoading; + + 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..ecfd37a4f --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogContent.tsx @@ -0,0 +1,15 @@ +// @ts-nocheck +import React from 'react'; +import TaxRateFormDialogForm from './TaxRateFormDialogForm'; +import { TaxRateFormDialogBoot } from './TaxRateFormDialogBoot'; + +/** + * Account dialog content. + */ +export default function TaxRateFormDialogContent({ dialogName, payload }) { + 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..74933746b --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogForm.tsx @@ -0,0 +1,124 @@ +// @ts-nocheck +import React, { useCallback } from 'react'; +import { Classes, Intent } from '@blueprintjs/core'; +import { Form, Formik } from 'formik'; +import { AppToaster } from '@/components'; + +import TaxRateFormDialogFormContent from './TaxRateFormDialogFormContent'; + +import withDialogActions from '@/containers/Dialog/withDialogActions'; +import { + CreateTaxRateFormSchema, + EditTaxRateFormSchema, +} from './TaxRateForm.schema'; +import { transformApiErrors, transformFormToReq } from './utils'; +import { useCreateTaxRate, useEditTaxRate } from '@/hooks/query/taxRates'; +import { useTaxRateFormDialogContext } from './TaxRateFormDialogBoot'; +import { TaxRateFormDialogFormFooter } from './TaxRateFormDialogFormFooter'; +import { compose, transformToForm } from '@/utils'; + +// Default initial form values. +const defaultInitialValues = { + name: '', + code: '', + rate: '', + description: '', + is_compound: false, + is_non_recoverable: false, +}; + +/** + * Tax rate form dialog content. + */ +function TaxRateFormDialogForm({ + // #withDialogActions + closeDialog, +}) { + // Account form context. + const { + account, + + payload, + isNewMode, + dialogName, + } = useTaxRateFormDialogContext(); + + // Form validation schema in create and edit mode. + const validationSchema = isNewMode + ? CreateTaxRateFormSchema + : EditTaxRateFormSchema; + + const { mutateAsync: createTaxRateMutate } = useCreateTaxRate(); + const { mutateAsync: editTaxRateMutate } = useEditTaxRate(); + + // Callbacks handles form submit. + const handleFormSubmit = (values, { setSubmitting, setErrors }) => { + const form = transformFormToReq(values); + + // 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 (payload.accountId) { + editTaxRateMutate([payload.accountId, form]) + .then(handleSuccess) + .catch(handleError); + } else { + createTaxRateMutate({ ...form }) + .then(handleSuccess) + .catch(handleError); + } + }; + // Form initial values in create and edit mode. + const initialValues = { + ...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(account, defaultInitialValues), + }; + // Handles dialog close. + const handleClose = () => { + closeDialog(dialogName); + }; + + return ( + + +
+ +
+ + +
+ ); +} + +export default compose(withDialogActions)(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..0c3bc540c --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogFormContent.tsx @@ -0,0 +1,77 @@ +import { + FCheckbox, + FFormGroup, + FInputGroup, + FieldHint, + Hint, +} from '@/components'; +import { Tag } from '@blueprintjs/core'; +import React from 'react'; +import styled from 'styled-components'; + +/** + * + * @returns + */ +export default function TaxRateFormDialogContent() { + return ( +
+ Required} + subLabel={ + 'The name as you would like it to appear in customers invoices.' + } + > + + + + Required} + > + + + + Required} + > + %} + fill={false} + /> + + + + } + > + + + + + + + + + + +
+ ); +} + +const RateFormGroup = styled(FInputGroup)` + max-width: 100px; +`; + +const CompoundFormGroup = styled(FFormGroup)` + margin-bottom: 0; +`; 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..b49bdc7eb --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/TaxRateFormDialogFormFooter.tsx @@ -0,0 +1,41 @@ +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..7a746cb6d --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/dialogs/TaxRateFormDialog/utils.ts @@ -0,0 +1,7 @@ +export const transformApiErrors = () => { + return {}; +}; + +export const transformFormToReq = () => { + return {}; +}; 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..e87d6f63b --- /dev/null +++ b/packages/webapp/src/containers/TaxRates/drawers/TaxRateDetailsDrawer/TaxRateDetailsContentActionsBar.tsx @@ -0,0 +1,71 @@ +// @ts-nocheck +import React from 'react'; +import { + Button, + Classes, + Intent, + NavbarDivider, + NavbarGroup, +} from '@blueprintjs/core'; +import * as R from 'ramda'; +import { 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'; + +/** + * Tax rate details content actions bar. + * @returns {JSX.Element} + */ +function TaxRateDetailsContentActionsBar({ + // #withDrawerActions + openDialog, + + // #withAlertsActions + openAlert, +}) { + const { taxRateId } = useTaxRateDetailsContext(); + + // Handle edit tax rate. + const handleEditTaxRate = () => { + openDialog(DialogsName.TaxRateForm, { id: taxRateId }); + }; + // Handle delete tax rate. + const handleDeleteTaxRate = () => { + openAlert('tax-rate-delete', { taxRateId }); + }; + + return ( + + + +
+ } + > +