From 336171081eb8ff97553d4f5ecf1b54ddfefa681d Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Sun, 22 Dec 2024 14:16:01 +0200 Subject: [PATCH] refactor: sale estimates to nestjs --- packages/server-nest/package.json | 7 +- .../server-nest/src/common/events/events.ts | 2 + .../common/filters/service-error.filter.ts | 24 ++ .../interceptors/serialize.interceptor.ts | 78 +++++ packages/server-nest/src/main.ts | 3 + .../server-nest/src/modules/App/App.module.ts | 7 + .../Attachments/Attachment.transformer.ts | 19 ++ .../modules/Attachments/Attachments.types.ts | 3 + .../AutoIncrementOrders.module.ts | 12 + .../AutoIncrementOrders.service.ts | 62 ++++ .../BranchTransactionDTOTransform.ts | 58 ++-- .../src/modules/Contacts/models/Contact.ts | 229 +++++++++++++ .../src/modules/Customers/models/Customer.ts | 234 +++++++++++++ .../src/modules/Expenses/Expenses.types.ts | 208 ++++++++++++ .../modules/Expenses/subscribers/ExpenseGL.ts | 192 +++++------ .../subscribers/ExpenseGLEntries.service.ts | 42 +++ .../ExpenseGLEntries.subscriber.ts | 77 +++++ .../subscribers/ExpenseGLEntriesService.ts | 45 --- .../subscribers/ExpenseGLEntriesStorage.ts | 129 ++++--- .../subscribers/ExpenseGLEntriesSubscriber.ts | 117 ------- .../src/modules/Items/ItemsEntries.service.ts | 252 ++++++++++++++ .../src/modules/Items/ItemsEntriesService.ts | 0 .../src/modules/Items/ServiceError.ts | 30 +- .../src/modules/Items/models/Item.ts | 7 + .../BrandingTemplateDTOTransformer.ts | 69 ++-- .../SaleEstimates.application.ts | 178 ++++++++++ .../SaleEstimates/SaleEstimates.controller.ts | 132 ++++++++ .../SaleEstimates/SaleEstimates.module.ts | 59 ++++ .../SaleEstimates/SaleEstimatesExportable.ts | 35 ++ .../SaleEstimates/SaleEstimatesImportable.ts | 45 +++ .../commands/ApproveSaleEstimate.service.ts | 68 ++++ .../commands/ConvetSaleEstimate.service.ts | 44 +++ .../commands/CreateSaleEstimate.service.ts | 86 +++++ .../commands/DeleteSaleEstimate.service.ts | 71 ++++ .../commands/DeliverSaleEstimate.service.ts | 63 ++++ .../commands/EditSaleEstimate.service.ts | 115 +++++++ .../commands/RejectSaleEstimate.service.ts | 50 +++ .../SaleEstimateDTOTransformer.service.ts | 115 +++++++ .../commands/SaleEstimateIncrement.service.ts | 28 ++ .../commands/SaleEstimateSmsNotify.ts | 217 ++++++++++++ .../SaleEstimateValidators.service.ts | 81 +++++ .../commands/SendSaleEstimateMail.ts | 205 +++++++++++ .../commands/SendSaleEstimateMailJob.ts | 36 ++ .../UnlinkConvertedSaleEstimate.service.ts | 30 ++ .../src/modules/SaleEstimates/constants.ts | 287 ++++++++++++++++ .../SaleEstimates/models/SaleEstimate.ts | 318 ++++++++++++++++++ .../queries/GetSaleEstimate.service.ts | 49 +++ .../queries/GetSaleEstimateState.service.ts | 26 ++ .../SaleEstimates/queries/GetSaleEstimates.ts | 79 +++++ .../queries/SaleEstimate.transformer.ts | 122 +++++++ .../SaleEstimates/queries/SaleEstimatesPdf.ts | 116 +++++++ .../SaleEstimateMarkApprovedOnMailSent.ts | 35 ++ .../types/SaleEstimates.types.ts | 122 +++++++ .../src/modules/SaleEstimates/utils.ts | 34 ++ .../SystemModels/SystemModels.module.ts | 1 - .../server-nest/src/modules/TaxRates/utils.ts | 19 ++ .../Tenancy/TenancyModels/Tenancy.module.ts | 6 + .../ItemEntry.transformer.ts | 49 +++ .../TransactionItemEntry/ItemEntry.types.ts | 22 ++ .../TransactionItemEntry/models/ItemEntry.ts | 244 ++++++++++++++ .../src/modules/Transformer/Transformer.ts | 10 +- .../WarehouseTransactionDTOTransform.ts | 67 ++-- .../src/utils/address-text-format.ts | 113 +++++++ .../src/utils/format-date-fields.ts | 23 ++ .../test/sale-estimates.e2e-spec.ts | 61 ++++ .../src/services/Items/ItemsEntriesService.ts | 82 ++--- src/errors/service-error.ts | 14 + src/filters/service-error.filter.ts | 19 ++ src/main.ts | 13 + 69 files changed, 4998 insertions(+), 497 deletions(-) create mode 100644 packages/server-nest/src/common/filters/service-error.filter.ts create mode 100644 packages/server-nest/src/common/interceptors/serialize.interceptor.ts create mode 100644 packages/server-nest/src/modules/Attachments/Attachment.transformer.ts create mode 100644 packages/server-nest/src/modules/Attachments/Attachments.types.ts create mode 100644 packages/server-nest/src/modules/AutoIncrementOrders/AutoIncrementOrders.module.ts create mode 100644 packages/server-nest/src/modules/AutoIncrementOrders/AutoIncrementOrders.service.ts create mode 100644 packages/server-nest/src/modules/Contacts/models/Contact.ts create mode 100644 packages/server-nest/src/modules/Customers/models/Customer.ts create mode 100644 packages/server-nest/src/modules/Expenses/Expenses.types.ts create mode 100644 packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntries.service.ts create mode 100644 packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntries.subscriber.ts delete mode 100644 packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesService.ts delete mode 100644 packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesSubscriber.ts create mode 100644 packages/server-nest/src/modules/Items/ItemsEntries.service.ts delete mode 100644 packages/server-nest/src/modules/Items/ItemsEntriesService.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/SaleEstimates.application.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/SaleEstimates.controller.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/SaleEstimates.module.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/SaleEstimatesExportable.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/SaleEstimatesImportable.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/commands/ApproveSaleEstimate.service.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/commands/ConvetSaleEstimate.service.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/commands/CreateSaleEstimate.service.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/commands/DeleteSaleEstimate.service.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/commands/DeliverSaleEstimate.service.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/commands/EditSaleEstimate.service.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/commands/RejectSaleEstimate.service.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/commands/SaleEstimateDTOTransformer.service.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/commands/SaleEstimateIncrement.service.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/commands/SaleEstimateSmsNotify.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/commands/SaleEstimateValidators.service.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/commands/SendSaleEstimateMail.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/commands/SendSaleEstimateMailJob.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/commands/UnlinkConvertedSaleEstimate.service.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/constants.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/models/SaleEstimate.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/queries/GetSaleEstimate.service.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/queries/GetSaleEstimateState.service.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/queries/GetSaleEstimates.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/queries/SaleEstimate.transformer.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/queries/SaleEstimatesPdf.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/subscribers/SaleEstimateMarkApprovedOnMailSent.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/types/SaleEstimates.types.ts create mode 100644 packages/server-nest/src/modules/SaleEstimates/utils.ts create mode 100644 packages/server-nest/src/modules/TaxRates/utils.ts create mode 100644 packages/server-nest/src/modules/TransactionItemEntry/ItemEntry.transformer.ts create mode 100644 packages/server-nest/src/modules/TransactionItemEntry/ItemEntry.types.ts create mode 100644 packages/server-nest/src/modules/TransactionItemEntry/models/ItemEntry.ts create mode 100644 packages/server-nest/src/utils/address-text-format.ts create mode 100644 packages/server-nest/src/utils/format-date-fields.ts create mode 100644 packages/server-nest/test/sale-estimates.e2e-spec.ts create mode 100644 src/errors/service-error.ts create mode 100644 src/filters/service-error.filter.ts create mode 100644 src/main.ts diff --git a/packages/server-nest/package.json b/packages/server-nest/package.json index 3405c2e3c..3bae968a5 100644 --- a/packages/server-nest/package.json +++ b/packages/server-nest/package.json @@ -34,9 +34,8 @@ "@nestjs/throttler": "^6.2.1", "@types/passport-local": "^1.0.38", "@types/ramda": "^0.30.2", - "js-money": "^0.6.3", "accounting": "^0.4.1", - "object-hash": "^2.0.3", + "async": "^3.2.0", "bull": "^4.16.3", "bullmq": "^5.21.1", "cache-manager": "^6.1.1", @@ -45,6 +44,7 @@ "class-validator": "^0.14.1", "express-validator": "^7.2.0", "fp-ts": "^2.16.9", + "js-money": "^0.6.3", "knex": "^3.1.0", "lamda": "^0.4.1", "lodash": "^4.17.21", @@ -53,6 +53,7 @@ "mysql2": "^3.11.3", "nestjs-cls": "^4.4.1", "nestjs-i18n": "^10.4.9", + "object-hash": "^2.0.3", "objection": "^3.1.5", "passport": "^0.7.0", "passport-jwt": "^4.0.1", @@ -61,6 +62,8 @@ "redis": "^4.7.0", "reflect-metadata": "^0.2.0", "rxjs": "^7.8.1", + "serialize-interceptor": "^1.1.7", + "strategy": "^1.1.1", "zod": "^3.23.8" }, "devDependencies": { diff --git a/packages/server-nest/src/common/events/events.ts b/packages/server-nest/src/common/events/events.ts index 60fda4ca7..0fdf57638 100644 --- a/packages/server-nest/src/common/events/events.ts +++ b/packages/server-nest/src/common/events/events.ts @@ -213,6 +213,8 @@ export const events = { onPreMailSend: 'onSaleEstimatePreMailSend', onMailSend: 'onSaleEstimateMailSend', onMailSent: 'onSaleEstimateMailSend', + + onViewed: 'onSaleEstimateViewed', }, /** diff --git a/packages/server-nest/src/common/filters/service-error.filter.ts b/packages/server-nest/src/common/filters/service-error.filter.ts new file mode 100644 index 000000000..146a63cff --- /dev/null +++ b/packages/server-nest/src/common/filters/service-error.filter.ts @@ -0,0 +1,24 @@ +import { + ExceptionFilter, + Catch, + ArgumentsHost, + HttpStatus, +} from '@nestjs/common'; +import { Response } from 'express'; +import { ServiceError } from '@/modules/Items/ServiceError'; + +@Catch(ServiceError) +export class ServiceErrorFilter implements ExceptionFilter { + catch(exception: ServiceError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const status = exception.getStatus(); + + response.status(status).json({ + statusCode: status, + errorType: exception.errorType, + message: exception.message, + payload: exception.payload, + }); + } +} diff --git a/packages/server-nest/src/common/interceptors/serialize.interceptor.ts b/packages/server-nest/src/common/interceptors/serialize.interceptor.ts new file mode 100644 index 000000000..a9dc5a48f --- /dev/null +++ b/packages/server-nest/src/common/interceptors/serialize.interceptor.ts @@ -0,0 +1,78 @@ +import { + type ExecutionContext, + Injectable, + type NestInterceptor, + type CallHandler, + Optional, +} from '@nestjs/common'; +import { type Observable } from 'rxjs'; +import { map } from 'rxjs/operators'; + +export function camelToSnake(value: T) { + if (value === null || value === undefined) { + return value; + } + if (Array.isArray(value)) { + return value.map(camelToSnake); + } + if (typeof value === 'object' && !(value instanceof Date)) { + return Object.fromEntries( + Object.entries(value).map(([key, value]) => [ + key + .split(/(?=[A-Z])/) + .join('_') + .toLowerCase(), + camelToSnake(value), + ]), + ); + } + return value; +} + +export function snakeToCamel(value: T) { + if (value === null || value === undefined) { + return value; + } + + if (Array.isArray(value)) { + return value.map(snakeToCamel); + } + + const impl = (str: string) => { + const converted = str.replace(/([-_]\w)/g, (group) => + group[1].toUpperCase(), + ); + return converted[0].toLowerCase() + converted.slice(1); + }; + + if (typeof value === 'object' && !(value instanceof Date)) { + return Object.fromEntries( + Object.entries(value).map(([key, value]) => [ + impl(key), + snakeToCamel(value), + ]), + ); + } + return value; +} + +export const DEFAULT_STRATEGY = { + in: snakeToCamel, + out: camelToSnake, +}; + +@Injectable() +export class SerializeInterceptor implements NestInterceptor { + constructor(@Optional() readonly strategy = DEFAULT_STRATEGY) {} + + intercept( + context: ExecutionContext, + next: CallHandler, + ): Observable { + const request = context.switchToHttp().getRequest(); + request.body = this.strategy.in(request.body); + + // handle returns stream.. + return next.handle().pipe(map(this.strategy.out)); + } +} diff --git a/packages/server-nest/src/main.ts b/packages/server-nest/src/main.ts index 78d305afe..ac95fd186 100644 --- a/packages/server-nest/src/main.ts +++ b/packages/server-nest/src/main.ts @@ -3,6 +3,7 @@ import { DocumentBuilder, SwaggerModule } from '@nestjs/swagger'; import { ClsMiddleware } from 'nestjs-cls'; import { AppModule } from './modules/App/App.module'; import './utils/moment-mysql'; +import { ServiceErrorFilter } from './common/filters/service-error.filter'; async function bootstrap() { const app = await NestFactory.create(AppModule); @@ -21,6 +22,8 @@ async function bootstrap() { const documentFactory = () => SwaggerModule.createDocument(app, config); SwaggerModule.setup('swagger', app, documentFactory); + app.useGlobalFilters(new ServiceErrorFilter()); + await app.listen(process.env.PORT ?? 3000); } bootstrap(); diff --git a/packages/server-nest/src/modules/App/App.module.ts b/packages/server-nest/src/modules/App/App.module.ts index c3483075f..f95b1b89b 100644 --- a/packages/server-nest/src/modules/App/App.module.ts +++ b/packages/server-nest/src/modules/App/App.module.ts @@ -38,6 +38,8 @@ import { TaxRatesModule } from '../TaxRates/TaxRate.module'; import { PdfTemplatesModule } from '../PdfTemplate/PdfTemplates.module'; import { BranchesModule } from '../Branches/Branches.module'; import { WarehousesModule } from '../Warehouses/Warehouses.module'; +import { SaleEstimatesModule } from '../SaleEstimates/SaleEstimates.module'; +import { SerializeInterceptor } from '@/common/interceptors/serialize.interceptor'; @Module({ imports: [ @@ -99,9 +101,14 @@ import { WarehousesModule } from '../Warehouses/Warehouses.module'; PdfTemplatesModule, BranchesModule, WarehousesModule, + SaleEstimatesModule, ], controllers: [AppController], providers: [ + { + provide: APP_INTERCEPTOR, + useClass: SerializeInterceptor, + }, { provide: APP_GUARD, useClass: JwtAuthGuard, diff --git a/packages/server-nest/src/modules/Attachments/Attachment.transformer.ts b/packages/server-nest/src/modules/Attachments/Attachment.transformer.ts new file mode 100644 index 000000000..25601b888 --- /dev/null +++ b/packages/server-nest/src/modules/Attachments/Attachment.transformer.ts @@ -0,0 +1,19 @@ +import { Transformer } from "../Transformer/Transformer"; + +export class AttachmentTransformer extends Transformer { + /** + * Exclude attributes. + * @returns {string[]} + */ + public excludeAttributes = (): string[] => { + return ['id', 'createdAt']; + }; + + /** + * Includeded attributes. + * @returns {string[]} + */ + public includeAttributes = (): string[] => { + return []; + }; +} diff --git a/packages/server-nest/src/modules/Attachments/Attachments.types.ts b/packages/server-nest/src/modules/Attachments/Attachments.types.ts new file mode 100644 index 000000000..d1699ed12 --- /dev/null +++ b/packages/server-nest/src/modules/Attachments/Attachments.types.ts @@ -0,0 +1,3 @@ +export interface AttachmentLinkDTO { + key: string; +} diff --git a/packages/server-nest/src/modules/AutoIncrementOrders/AutoIncrementOrders.module.ts b/packages/server-nest/src/modules/AutoIncrementOrders/AutoIncrementOrders.module.ts new file mode 100644 index 000000000..324c285b6 --- /dev/null +++ b/packages/server-nest/src/modules/AutoIncrementOrders/AutoIncrementOrders.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { TransformerInjectable } from '../Transformer/TransformerInjectable.service'; +import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module'; +import { AutoIncrementOrdersService } from './AutoIncrementOrders.service'; + +@Module({ + imports: [TenancyDatabaseModule], + controllers: [], + providers: [AutoIncrementOrdersService, TransformerInjectable], + exports: [AutoIncrementOrdersService], +}) +export class AutoIncrementOrdersModule {} diff --git a/packages/server-nest/src/modules/AutoIncrementOrders/AutoIncrementOrders.service.ts b/packages/server-nest/src/modules/AutoIncrementOrders/AutoIncrementOrders.service.ts new file mode 100644 index 000000000..ea3cc551c --- /dev/null +++ b/packages/server-nest/src/modules/AutoIncrementOrders/AutoIncrementOrders.service.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; + +/** + * Auto increment orders service. + */ +@Injectable() +export class AutoIncrementOrdersService { + /** + * Check if the auto increment is enabled for the given settings group. + * @param {string} settingsGroup - Settings group. + * @returns {boolean} + */ + public autoIncrementEnabled = (settingsGroup: string): boolean => { + // const settings = this.tenancy.settings(tenantId); + // const group = settingsGroup; + + // // Settings service transaction number and prefix. + // return settings.get({ group, key: 'auto_increment' }, false); + + return true; + }; + + /** + * Retrieve the next service transaction number. + * @param {string} settingsGroup + * @param {Function} getMaxTransactionNo + * @return {Promise} + */ + getNextTransactionNumber(group: string): string { + // const settings = this.tenancy.settings(tenantId); + + // // Settings service transaction number and prefix. + // const autoIncrement = this.autoIncrementEnabled(tenantId, group); + + // const settingNo = settings.get({ group, key: 'next_number' }, ''); + // const settingPrefix = settings.get({ group, key: 'number_prefix' }, ''); + + // return autoIncrement ? `${settingPrefix}${settingNo}` : ''; + + return '1'; + } + + /** + * Increment setting next number. + * @param {string} orderGroup - Order group. + * @param {string} orderNumber -Order number. + */ + async incrementSettingsNextNumber(group: string) { + // const settings = this.tenancy.settings(tenantId); + // const settingNo = settings.get({ group, key: 'next_number' }); + // const autoIncrement = settings.get({ group, key: 'auto_increment' }); + // // Can't continue if the auto-increment of the service was disabled. + // if (!autoIncrement) { + // return; + // } + // settings.set( + // { group, key: 'next_number' }, + // transactionIncrement(settingNo) + // ); + // await settings.save(); + } +} diff --git a/packages/server-nest/src/modules/Branches/integrations/BranchTransactionDTOTransform.ts b/packages/server-nest/src/modules/Branches/integrations/BranchTransactionDTOTransform.ts index 97c3329bc..5bdfcb43f 100644 --- a/packages/server-nest/src/modules/Branches/integrations/BranchTransactionDTOTransform.ts +++ b/packages/server-nest/src/modules/Branches/integrations/BranchTransactionDTOTransform.ts @@ -1,35 +1,31 @@ -// import { Service, Inject } from 'typedi'; -// import { omit } from 'lodash'; -// import { BranchesSettings } from '../BranchesSettings'; +import { Inject, Injectable } from '@nestjs/common'; +import { omit } from 'lodash'; +import { BranchesSettingsService } from '../BranchesSettings'; -// @Service() -// export class BranchTransactionDTOTransform { -// @Inject() -// branchesSettings: BranchesSettings; +@Injectable() +export class BranchTransactionDTOTransformer { + constructor(private readonly branchesSettings: BranchesSettingsService) {} -// /** -// * Excludes DTO branch id when mutli-warehouses feature is inactive. -// * @param {number} tenantId -// * @returns {any} -// */ -// private excludeDTOBranchIdWhenInactive = ( -// tenantId: number, -// DTO: T -// ): Omit | T => { -// const isActive = this.branchesSettings.isMultiBranchesActive(tenantId); + /** + * Excludes DTO branch id when mutli-warehouses feature is inactive. + * @returns {any} + */ + private excludeDTOBranchIdWhenInactive = ( + DTO: T, + ): Omit | T => { + const isActive = this.branchesSettings.isMultiBranchesActive(); -// return !isActive ? omit(DTO, ['branchId']) : DTO; -// }; + return !isActive ? omit(DTO, ['branchId']) : DTO; + }; -// /** -// * Transformes the input DTO for branches feature. -// * @param {number} tenantId - -// * @param {T} DTO - -// * @returns {Omit | T} -// */ -// public transformDTO = -// (tenantId: number) => -// (DTO: T): Omit | T => { -// return this.excludeDTOBranchIdWhenInactive(tenantId, DTO); -// }; -// } + /** + * Transformes the input DTO for branches feature. + * @param {T} DTO - + * @returns {Omit | T} + */ + public transformDTO = ( + DTO: T, + ): Omit | T => { + return this.excludeDTOBranchIdWhenInactive(DTO); + }; +} diff --git a/packages/server-nest/src/modules/Contacts/models/Contact.ts b/packages/server-nest/src/modules/Contacts/models/Contact.ts new file mode 100644 index 000000000..1e43af419 --- /dev/null +++ b/packages/server-nest/src/modules/Contacts/models/Contact.ts @@ -0,0 +1,229 @@ +import { Model } from 'objection'; +import { BaseModel } from '@/models/Model'; + +export class Contact extends BaseModel { + contactService: string; + contactType: string; + + balance: number; + currencyCode: string; + + openingBalance: number; + openingBalanceAt: Date; + + salutation?: string; + firstName?: string; + lastName?: string; + companyName?: string; + + displayName: string; + + email?: string; + workPhone?: string; + personalPhone?: string; + website?: string; + + billingAddress1?: string; + billingAddress2?: string; + billingAddressCity?: string; + billingAddressCountry?: string; + billingAddressEmail?: string; + billingAddressPostcode?: string; + billingAddressPhone?: string; + billingAddressState?: string; + + shippingAddress1?: string; + shippingAddress2?: string; + shippingAddressCity?: string; + shippingAddressCountry?: string; + shippingAddressEmail?: string; + shippingAddressPostcode?: string; + shippingAddressPhone?: string; + shippingAddressState?: string; + + note: string; + active: boolean; + + /** + * Table name + */ + static get tableName() { + return 'contacts'; + } + + /** + * Model timestamps. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Defined virtual attributes. + */ + static get virtualAttributes() { + return ['contactNormal', 'closingBalance', 'formattedContactService']; + } + + /** + * Retrieve the contact normal by the given contact type. + */ + static getContactNormalByType(contactType) { + const types = { + vendor: 'credit', + customer: 'debit', + }; + return types[contactType]; + } + + /** + * Retrieve the contact normal by the given contact service. + * @param {string} contactService + */ + static getFormattedContactService(contactService) { + const types = { + customer: 'Customer', + vendor: 'Vendor', + }; + return types[contactService]; + } + + /** + * Retrieve the contact normal. + */ + get contactNormal() { + return Contact.getContactNormalByType(this.contactService); + } + + /** + * Retrieve formatted contact service. + */ + get formattedContactService() { + return Contact.getFormattedContactService(this.contactService); + } + + /** + * Closing balance attribute. + */ + get closingBalance() { + return this.balance; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + filterContactIds(query, customerIds) { + query.whereIn('id', customerIds); + }, + + customer(query) { + query.where('contact_service', 'customer'); + }, + + vendor(query) { + query.where('contact_service', 'vendor'); + }, + }; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const SaleEstimate = require('models/SaleEstimate'); + const SaleReceipt = require('models/SaleReceipt'); + const SaleInvoice = require('models/SaleInvoice'); + const PaymentReceive = require('models/PaymentReceive'); + const Bill = require('models/Bill'); + const BillPayment = require('models/BillPayment'); + const AccountTransaction = require('models/AccountTransaction'); + + return { + /** + * Contact may has many sales invoices. + */ + salesInvoices: { + relation: Model.HasManyRelation, + modelClass: SaleInvoice.default, + join: { + from: 'contacts.id', + to: 'sales_invoices.customerId', + }, + }, + + /** + * Contact may has many sales estimates. + */ + salesEstimates: { + relation: Model.HasManyRelation, + modelClass: SaleEstimate.default, + join: { + from: 'contacts.id', + to: 'sales_estimates.customerId', + }, + }, + + /** + * Contact may has many sales receipts. + */ + salesReceipts: { + relation: Model.HasManyRelation, + modelClass: SaleReceipt.default, + join: { + from: 'contacts.id', + to: 'sales_receipts.customerId', + }, + }, + + /** + * Contact may has many payments receives. + */ + paymentReceives: { + relation: Model.HasManyRelation, + modelClass: PaymentReceive.default, + join: { + from: 'contacts.id', + to: 'payment_receives.customerId', + }, + }, + + /** + * Contact may has many bills. + */ + bills: { + relation: Model.HasManyRelation, + modelClass: Bill.default, + join: { + from: 'contacts.id', + to: 'bills.vendorId', + }, + }, + + /** + * Contact may has many bills payments. + */ + billPayments: { + relation: Model.HasManyRelation, + modelClass: BillPayment.default, + join: { + from: 'contacts.id', + to: 'bills_payments.vendorId', + }, + }, + + /** + * Contact may has many accounts transactions. + */ + accountsTransactions: { + relation: Model.HasManyRelation, + modelClass: AccountTransaction.default, + join: { + from: 'contacts.id', + to: 'accounts_transactions.contactId', + }, + }, + }; + } +} diff --git a/packages/server-nest/src/modules/Customers/models/Customer.ts b/packages/server-nest/src/modules/Customers/models/Customer.ts new file mode 100644 index 000000000..6a6707e46 --- /dev/null +++ b/packages/server-nest/src/modules/Customers/models/Customer.ts @@ -0,0 +1,234 @@ +import { Model, mixin } from 'objection'; +import { BaseModel } from '@/models/Model'; +// import TenantModel from 'models/TenantModel'; +// import PaginationQueryBuilder from './Pagination'; +// import ModelSetting from './ModelSetting'; +// import CustomerSettings from './Customer.Settings'; +// import CustomViewBaseModel from './CustomViewBaseModel'; +// import { DEFAULT_VIEWS } from '@/services/Contacts/Customers/constants'; +// import ModelSearchable from './ModelSearchable'; + +// class CustomerQueryBuilder extends PaginationQueryBuilder { +// constructor(...args) { +// super(...args); + +// this.onBuild((builder) => { +// if (builder.isFind() || builder.isDelete() || builder.isUpdate()) { +// builder.where('contact_service', 'customer'); +// } +// }); +// } +// } + +export class Customer extends BaseModel{ + contactService: string; + contactType: string; + + balance: number; + currencyCode: string; + + openingBalance: number; + openingBalanceAt: Date; + openingBalanceExchangeRate: number; + + salutation?: string; + firstName?: string; + lastName?: string; + companyName?: string; + + displayName: string; + + email?: string; + workPhone?: string; + personalPhone?: string; + website?: string; + + billingAddress1?: string; + billingAddress2?: string; + billingAddressCity?: string; + billingAddressCountry?: string; + billingAddressEmail?: string; + billingAddressPostcode?: string; + billingAddressPhone?: string; + billingAddressState?: string; + + shippingAddress1?: string; + shippingAddress2?: string; + shippingAddressCity?: string; + shippingAddressCountry?: string; + shippingAddressEmail?: string; + shippingAddressPostcode?: string; + shippingAddressPhone?: string; + shippingAddressState?: string; + + note: string; + active: boolean; + + + /** + * Query builder. + */ + // static get QueryBuilder() { + // return CustomerQueryBuilder; + // } + + /** + * Table name + */ + static get tableName() { + return 'contacts'; + } + + /** + * Model timestamps. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Defined virtual attributes. + */ + static get virtualAttributes() { + return ['localOpeningBalance', 'closingBalance', 'contactNormal']; + } + + /** + * Closing balance attribute. + */ + get closingBalance() { + return this.balance; + } + + /** + * Retrieves the local opening balance. + * @returns {number} + */ + get localOpeningBalance() { + return this.openingBalance + ? this.openingBalance * this.openingBalanceExchangeRate + : 0; + } + + /** + * Retrieve the contact noraml; + */ + get contactNormal() { + return 'debit'; + } + + /** + * + */ + get contactAddresses() { + return [ + { + mail: this.email, + label: this.displayName, + primary: true + }, + ].filter((c) => c.mail); + } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + /** + * Inactive/Active mode. + */ + inactiveMode(query, active = false) { + query.where('active', !active); + }, + + /** + * Filters the active customers. + */ + active(query) { + query.where('active', 1); + }, + /** + * Filters the inactive customers. + */ + inactive(query) { + query.where('active', 0); + }, + /** + * Filters the customers that have overdue invoices. + */ + overdue(query) { + query.select( + '*', + Customer.relatedQuery('overDueInvoices', query.knex()) + .count() + .as('countOverdue') + ); + query.having('countOverdue', '>', 0); + }, + /** + * Filters the unpaid customers. + */ + unpaid(query) { + query.whereRaw('`BALANCE` + `OPENING_BALANCE` <> 0'); + }, + }; + } + + /** + * Relationship mapping. + */ + // static get relationMappings() { + // const SaleInvoice = require('models/SaleInvoice'); + + // return { + // salesInvoices: { + // relation: Model.HasManyRelation, + // modelClass: SaleInvoice.default, + // join: { + // from: 'contacts.id', + // to: 'sales_invoices.customerId', + // }, + // }, + + // overDueInvoices: { + // relation: Model.HasManyRelation, + // modelClass: SaleInvoice.default, + // join: { + // from: 'contacts.id', + // to: 'sales_invoices.customerId', + // }, + // filter: (query) => { + // query.modify('overdue'); + // }, + // }, + // }; + // } + + // static get meta() { + // return CustomerSettings; + // } + + // /** + // * Retrieve the default custom views, roles and columns. + // */ + // static get defaultViews() { + // return DEFAULT_VIEWS; + // } + + /** + * Model search attributes. + */ + static get searchRoles() { + return [ + { fieldKey: 'display_name', comparator: 'contains' }, + { condition: 'or', fieldKey: 'first_name', comparator: 'contains' }, + { condition: 'or', fieldKey: 'last_name', comparator: 'equals' }, + { condition: 'or', fieldKey: 'company_name', comparator: 'equals' }, + { condition: 'or', fieldKey: 'email', comparator: 'equals' }, + { condition: 'or', fieldKey: 'work_phone', comparator: 'equals' }, + { condition: 'or', fieldKey: 'personal_phone', comparator: 'equals' }, + { condition: 'or', fieldKey: 'website', comparator: 'equals' }, + ]; + } +} diff --git a/packages/server-nest/src/modules/Expenses/Expenses.types.ts b/packages/server-nest/src/modules/Expenses/Expenses.types.ts new file mode 100644 index 000000000..2b05e3f72 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/Expenses.types.ts @@ -0,0 +1,208 @@ +import { Knex } from 'knex'; +import { Expense } from './models/Expense.model'; +import { SystemUser } from '../System/models/SystemUser'; +import { IFilterRole } from '../DynamicListing/DynamicFilter/DynamicFilter.types'; +// import { ISystemUser } from './User'; +// import { IFilterRole } from './DynamicFilter'; +// import { IAccount } from './Account'; +// import { AttachmentLinkDTO } from './Attachments'; + +export interface IPaginationMeta { + total: number; + page: number; + pageSize: number; +} + +export interface IExpensesFilter { + page: number; + pageSize: number; + filterRoles?: IFilterRole[]; + columnSortBy: string; + sortOrder: string; + viewSlug?: string; + filterQuery?: (query: any) => void; +} + +// export interface IExpense { +// id: number; +// totalAmount: number; +// localAmount?: number; +// currencyCode: string; +// exchangeRate: number; +// description?: string; +// paymentAccountId: number; +// peyeeId?: number; +// referenceNo?: string; +// publishedAt: Date | null; +// userId: number; +// paymentDate: Date; +// payeeId: number; +// landedCostAmount: number; +// allocatedCostAmount: number; +// unallocatedCostAmount: number; +// categories?: IExpenseCategory[]; +// isPublished: boolean; + +// localLandedCostAmount?: number; +// localAllocatedCostAmount?: number; +// localUnallocatedCostAmount?: number; + +// billableAmount: number; +// invoicedAmount: number; + +// branchId?: number; + +// createdAt?: Date; +// } + +// export interface IExpenseCategory { +// id?: number; +// expenseAccountId: number; +// index: number; +// description: string; +// expenseId: number; +// amount: number; + +// projectId?: number; + +// allocatedCostAmount: number; +// unallocatedCostAmount: number; +// landedCost: boolean; + +// expenseAccount?: IAccount; +// } + +export interface IExpenseCommonDTO { + currencyCode: string; + exchangeRate?: number; + description?: string; + paymentAccountId: number; + peyeeId?: number; + referenceNo?: string; + publish: boolean; + userId: number; + paymentDate: Date; + payeeId: number; + categories: IExpenseCategoryDTO[]; + + branchId?: number; + // attachments?: AttachmentLinkDTO[]; +} + +export interface IExpenseCreateDTO extends IExpenseCommonDTO {} +export interface IExpenseEditDTO extends IExpenseCommonDTO {} + +export interface IExpenseCategoryDTO { + id?: number; + expenseAccountId: number; + index: number; + amount: number; + description?: string; + expenseId: number; + landedCost?: boolean; + projectId?: number; +} + +// export interface IExpensesService { +// newExpense( +// tenantid: number, +// expenseDTO: IExpenseDTO, +// authorizedUser: ISystemUser +// ): Promise; + +// editExpense( +// tenantid: number, +// expenseId: number, +// expenseDTO: IExpenseDTO, +// authorizedUser: ISystemUser +// ): void; + +// publishExpense( +// tenantId: number, +// expenseId: number, +// authorizedUser: ISystemUser +// ): Promise; + +// deleteExpense( +// tenantId: number, +// expenseId: number, +// authorizedUser: ISystemUser +// ): Promise; + +// getExpensesList( +// tenantId: number, +// expensesFilter: IExpensesFilter +// ): Promise<{ +// expenses: IExpense[]; +// pagination: IPaginationMeta; +// filterMeta: IFilterMeta; +// }>; + +// getExpense(tenantId: number, expenseId: number): Promise; +// } + +export interface IExpenseCreatingPayload { + trx: Knex.Transaction; + // tenantId: number; + expenseDTO: IExpenseCreateDTO; +} + +export interface IExpenseEventEditingPayload { + // tenantId: number; + oldExpense: Expense; + expenseDTO: IExpenseEditDTO; + trx: Knex.Transaction; +} + +export interface IExpenseCreatedPayload { + // tenantId: number; + expenseId: number; + // authorizedUser: ISystemUser; + expense: Expense; + expenseDTO: IExpenseCreateDTO; + trx?: Knex.Transaction; +} + +export interface IExpenseEventEditPayload { + // tenantId: number; + expenseId: number; + expense: Expense; + expenseDTO: IExpenseEditDTO; + authorizedUser: SystemUser; + oldExpense: Expense; + trx: Knex.Transaction; +} + +export interface IExpenseEventDeletePayload { + // tenantId: number; + expenseId: number; + authorizedUser: SystemUser; + oldExpense: Expense; + trx: Knex.Transaction; +} + +export interface IExpenseDeletingPayload { + trx: Knex.Transaction; + // tenantId: number; + oldExpense: Expense; +} +export interface IExpenseEventPublishedPayload { + // tenantId: number; + expenseId: number; + oldExpense: Expense; + expense: Expense; + authorizedUser: SystemUser; + trx: Knex.Transaction; +} + +export interface IExpensePublishingPayload { + trx: Knex.Transaction; + oldExpense: Expense; + // tenantId: number; +} +export enum ExpenseAction { + Create = 'Create', + Edit = 'Edit', + Delete = 'Delete', + View = 'View', +} diff --git a/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGL.ts b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGL.ts index ba437af98..417dba013 100644 --- a/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGL.ts +++ b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGL.ts @@ -1,113 +1,113 @@ -import * as R from 'ramda'; -import { - AccountNormal, - IExpenseCategory, - ILedger, - ILedgerEntry, -} from '@/interfaces'; -import Ledger from '../Accounting/Ledger'; +// import * as R from 'ramda'; +// import { +// AccountNormal, +// IExpenseCategory, +// ILedger, +// ILedgerEntry, +// } from '@/interfaces'; +// import Ledger from '../Accounting/Ledger'; -export class ExpenseGL { - private expense: any; +// export class ExpenseGL { +// private expense: any; - /** - * Constructor method. - */ - constructor(expense: any) { - this.expense = expense; - } +// /** +// * Constructor method. +// */ +// constructor(expense: any) { +// this.expense = expense; +// } - /** - * Retrieves the expense GL common entry. - * @param {IExpense} expense - * @returns {Partial} - */ - private getExpenseGLCommonEntry = (): Partial => { - return { - currencyCode: this.expense.currencyCode, - exchangeRate: this.expense.exchangeRate, +// /** +// * Retrieves the expense GL common entry. +// * @param {IExpense} expense +// * @returns {Partial} +// */ +// private getExpenseGLCommonEntry = (): Partial => { +// return { +// currencyCode: this.expense.currencyCode, +// exchangeRate: this.expense.exchangeRate, - transactionType: 'Expense', - transactionId: this.expense.id, +// transactionType: 'Expense', +// transactionId: this.expense.id, - date: this.expense.paymentDate, - userId: this.expense.userId, +// date: this.expense.paymentDate, +// userId: this.expense.userId, - debit: 0, - credit: 0, +// debit: 0, +// credit: 0, - branchId: this.expense.branchId, - }; - }; +// branchId: this.expense.branchId, +// }; +// }; - /** - * Retrieves the expense GL payment entry. - * @param {IExpense} expense - * @returns {ILedgerEntry} - */ - private getExpenseGLPaymentEntry = (): ILedgerEntry => { - const commonEntry = this.getExpenseGLCommonEntry(); +// /** +// * Retrieves the expense GL payment entry. +// * @param {IExpense} expense +// * @returns {ILedgerEntry} +// */ +// private getExpenseGLPaymentEntry = (): ILedgerEntry => { +// const commonEntry = this.getExpenseGLCommonEntry(); - return { - ...commonEntry, - credit: this.expense.localAmount, - accountId: this.expense.paymentAccountId, - accountNormal: - this.expense?.paymentAccount?.accountNormal === 'debit' - ? AccountNormal.DEBIT - : AccountNormal.CREDIT, - index: 1, - }; - }; +// return { +// ...commonEntry, +// credit: this.expense.localAmount, +// accountId: this.expense.paymentAccountId, +// accountNormal: +// this.expense?.paymentAccount?.accountNormal === 'debit' +// ? AccountNormal.DEBIT +// : AccountNormal.CREDIT, +// index: 1, +// }; +// }; - /** - * Retrieves the expense GL category entry. - * @param {IExpense} expense - - * @param {IExpenseCategory} expenseCategory - - * @param {number} index - * @returns {ILedgerEntry} - */ - private getExpenseGLCategoryEntry = R.curry( - (category: IExpenseCategory, index: number): ILedgerEntry => { - const commonEntry = this.getExpenseGLCommonEntry(); - const localAmount = category.amount * this.expense.exchangeRate; +// /** +// * Retrieves the expense GL category entry. +// * @param {IExpense} expense - +// * @param {IExpenseCategory} expenseCategory - +// * @param {number} index +// * @returns {ILedgerEntry} +// */ +// private getExpenseGLCategoryEntry = R.curry( +// (category: IExpenseCategory, index: number): ILedgerEntry => { +// const commonEntry = this.getExpenseGLCommonEntry(); +// const localAmount = category.amount * this.expense.exchangeRate; - return { - ...commonEntry, - accountId: category.expenseAccountId, - accountNormal: AccountNormal.DEBIT, - debit: localAmount, - note: category.description, - index: index + 2, - projectId: category.projectId, - }; - } - ); +// return { +// ...commonEntry, +// accountId: category.expenseAccountId, +// accountNormal: AccountNormal.DEBIT, +// debit: localAmount, +// note: category.description, +// index: index + 2, +// projectId: category.projectId, +// }; +// } +// ); - /** - * Retrieves the expense GL entries. - * @param {IExpense} expense - * @returns {ILedgerEntry[]} - */ - public getExpenseGLEntries = (): ILedgerEntry[] => { - const getCategoryEntry = this.getExpenseGLCategoryEntry(); +// /** +// * Retrieves the expense GL entries. +// * @param {IExpense} expense +// * @returns {ILedgerEntry[]} +// */ +// public getExpenseGLEntries = (): ILedgerEntry[] => { +// const getCategoryEntry = this.getExpenseGLCategoryEntry(); - const paymentEntry = this.getExpenseGLPaymentEntry(); - const categoryEntries = this.expense.categories.map(getCategoryEntry); +// const paymentEntry = this.getExpenseGLPaymentEntry(); +// const categoryEntries = this.expense.categories.map(getCategoryEntry); - return [paymentEntry, ...categoryEntries]; - }; +// return [paymentEntry, ...categoryEntries]; +// }; - /** - * Retrieves the given expense ledger. - * @param {IExpense} expense - * @returns {ILedger} - */ - public getExpenseLedger = (): ILedger => { - const entries = this.getExpenseGLEntries(); +// /** +// * Retrieves the given expense ledger. +// * @param {IExpense} expense +// * @returns {ILedger} +// */ +// public getExpenseLedger = (): ILedger => { +// const entries = this.getExpenseGLEntries(); - console.log(entries, 'entries'); +// console.log(entries, 'entries'); - return new Ledger(entries); - }; -} +// return new Ledger(entries); +// }; +// } diff --git a/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntries.service.ts b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntries.service.ts new file mode 100644 index 000000000..d3f2f552c --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntries.service.ts @@ -0,0 +1,42 @@ +// import { Knex } from 'knex'; +// import { ExpenseGL } from './ExpenseGL'; +// import { Inject, Injectable } from '@nestjs/common'; +// import { Expense } from '../models/Expense.model'; + +// @Injectable() +// export class ExpenseGLEntries { +// constructor( +// @Inject(Expense.name) +// private readonly expense: typeof Expense, +// ) {} +// /** +// * Retrieves the expense G/L of the given id. +// * @param {number} expenseId +// * @param {Knex.Transaction} trx +// * @returns {Promise} +// */ +// public getExpenseLedgerById = async ( +// expenseId: number, +// trx?: Knex.Transaction, +// ): Promise => { +// const expense = await this.expense +// .query(trx) +// .findById(expenseId) +// .withGraphFetched('categories') +// .withGraphFetched('paymentAccount') +// .throwIfNotFound(); + +// return this.getExpenseLedger(expense); +// }; + +// /** +// * Retrieves the given expense ledger. +// * @param {IExpense} expense +// * @returns {ILedger} +// */ +// public getExpenseLedger = (expense: Expense): ILedger => { +// const expenseGL = new ExpenseGL(expense); + +// return expenseGL.getExpenseLedger(); +// }; +// } diff --git a/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntries.subscriber.ts b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntries.subscriber.ts new file mode 100644 index 000000000..d607eefa3 --- /dev/null +++ b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntries.subscriber.ts @@ -0,0 +1,77 @@ +// import { +// IExpenseCreatedPayload, +// IExpenseEventDeletePayload, +// IExpenseEventEditPayload, +// IExpenseEventPublishedPayload, +// } from '../Expenses.types'; +// import { ExpenseGLEntriesStorage } from './ExpenseGLEntriesStorage'; +// import { Injectable } from '@nestjs/common'; +// import { OnEvent } from '@nestjs/event-emitter'; +// import { events } from '@/common/events/events'; + +// @Injectable() +// export class ExpensesWriteGLSubscriber { +// /** +// * @param {ExpenseGLEntriesStorage} expenseGLEntries - +// */ +// constructor(private readonly expenseGLEntries: ExpenseGLEntriesStorage) {} + +// /** +// * Handles the writing journal entries once the expense created. +// * @param {IExpenseCreatedPayload} payload - +// */ +// @OnEvent(events.expenses.onCreated) +// public async handleWriteGLEntriesOnceCreated({ +// expense, +// trx, +// }: IExpenseCreatedPayload) { +// // In case expense published, write journal entries. +// if (!expense.publishedAt) return; + +// await this.expenseGLEntries.writeExpenseGLEntries(expense.id, trx); +// } + +// /** +// * Handle writing expense journal entries once the expense edited. +// * @param {IExpenseEventEditPayload} payload - +// */ +// @OnEvent(events.expenses.onEdited) +// public async handleRewriteGLEntriesOnceEdited({ +// expenseId, +// expense, +// authorizedUser, +// trx, +// }: IExpenseEventEditPayload) { +// // Cannot continue if the expense is not published. +// if (!expense.publishedAt) return; + +// await this.expenseGLEntries.rewriteExpenseGLEntries(expense.id, trx); +// } + +// /** +// * Reverts expense journal entries once the expense deleted. +// * @param {IExpenseEventDeletePayload} payload - +// */ +// @OnEvent(events.expenses.onDeleted) +// public async handleRevertGLEntriesOnceDeleted({ +// expenseId, +// trx, +// }: IExpenseEventDeletePayload) { +// await this.expenseGLEntries.revertExpenseGLEntries(expenseId, trx); +// } + +// /** +// * Handles writing expense journal once the expense publish. +// * @param {IExpenseEventPublishedPayload} payload - +// */ +// @OnEvent(events.expenses.onPublished) +// public async handleWriteGLEntriesOncePublished({ +// expense, +// trx, +// }: IExpenseEventPublishedPayload) { +// // In case expense published, write journal entries. +// if (!expense.publishedAt) return; + +// await this.expenseGLEntries.rewriteExpenseGLEntries(expense.id, trx); +// } +// } diff --git a/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesService.ts b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesService.ts deleted file mode 100644 index 8502fb574..000000000 --- a/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesService.ts +++ /dev/null @@ -1,45 +0,0 @@ -import { Knex } from 'knex'; -import { Inject, Service } from 'typedi'; -import { IExpense, ILedger } from '@/interfaces'; -import { ExpenseGL } from './ExpenseGL'; -import HasTenancyService from '../Tenancy/TenancyService'; - -@Service() -export class ExpenseGLEntries { - @Inject() - private tenancy: HasTenancyService; - - /** - * Retrieves the expense G/L of the given id. - * @param {number} tenantId - * @param {number} expenseId - * @param {Knex.Transaction} trx - * @returns {Promise} - */ - public getExpenseLedgerById = async ( - tenantId: number, - expenseId: number, - trx?: Knex.Transaction - ): Promise => { - const { Expense } = await this.tenancy.models(tenantId); - - const expense = await Expense.query(trx) - .findById(expenseId) - .withGraphFetched('categories') - .withGraphFetched('paymentAccount') - .throwIfNotFound(); - - return this.getExpenseLedger(expense); - }; - - /** - * Retrieves the given expense ledger. - * @param {IExpense} expense - * @returns {ILedger} - */ - public getExpenseLedger = (expense: IExpense): ILedger => { - const expenseGL = new ExpenseGL(expense); - - return expenseGL.getExpenseLedger(); - }; -} diff --git a/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesStorage.ts b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesStorage.ts index ac451a2e2..2a5444116 100644 --- a/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesStorage.ts +++ b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesStorage.ts @@ -1,72 +1,67 @@ -import { Knex } from 'knex'; -import { Service, Inject } from 'typedi'; -import LedgerStorageService from '@/services/Accounting/LedgerStorageService'; -import HasTenancyService from '@/services/Tenancy/TenancyService'; -import { ExpenseGLEntries } from './ExpenseGLEntriesService'; +// import { Knex } from 'knex'; +// import { ExpenseGLEntries } from './ExpenseGLEntries.service'; +// import { Injectable } from '@nestjs/common'; -@Service() -export class ExpenseGLEntriesStorage { - @Inject() - private expenseGLEntries: ExpenseGLEntries; +// @Injectable() +// export class ExpenseGLEntriesStorage { +// /** +// * @param {ExpenseGLEntries} expenseGLEntries +// * @param {LedgerStorageService} ledgerStorage +// */ +// constructor( +// private readonly expenseGLEntries: ExpenseGLEntries, +// private readonly ledgerStorage: LedgerStorageService, +// ) {} - @Inject() - private ledgerStorage: LedgerStorageService; +// /** +// * Writes the expense GL entries. +// * @param {number} tenantId +// * @param {number} expenseId +// * @param {Knex.Transaction} trx +// */ +// public writeExpenseGLEntries = async ( +// expenseId: number, +// trx?: Knex.Transaction, +// ) => { +// // Retrieves the given expense ledger. +// const expenseLedger = await this.expenseGLEntries.getExpenseLedgerById( +// expenseId, +// trx, +// ); +// // Commits the expense ledger entries. +// await this.ledgerStorage.commit(expenseLedger, trx); +// }; - /** - * Writes the expense GL entries. - * @param {number} tenantId - * @param {number} expenseId - * @param {Knex.Transaction} trx - */ - public writeExpenseGLEntries = async ( - tenantId: number, - expenseId: number, - trx?: Knex.Transaction - ) => { - // Retrieves the given expense ledger. - const expenseLedger = await this.expenseGLEntries.getExpenseLedgerById( - tenantId, - expenseId, - trx - ); - // Commits the expense ledger entries. - await this.ledgerStorage.commit(tenantId, expenseLedger, trx); - }; +// /** +// * Reverts the given expense GL entries. +// * @param {number} tenantId +// * @param {number} expenseId +// * @param {Knex.Transaction} trx +// */ +// public revertExpenseGLEntries = async ( +// expenseId: number, +// trx?: Knex.Transaction, +// ) => { +// await this.ledgerStorage.deleteByReference( +// expenseId, +// 'Expense', +// trx, +// ); +// }; - /** - * Reverts the given expense GL entries. - * @param {number} tenantId - * @param {number} expenseId - * @param {Knex.Transaction} trx - */ - public revertExpenseGLEntries = async ( - tenantId: number, - expenseId: number, - trx?: Knex.Transaction - ) => { - await this.ledgerStorage.deleteByReference( - tenantId, - expenseId, - 'Expense', - trx - ); - }; +// /** +// * Rewrites the expense GL entries. +// * @param {number} expenseId +// * @param {Knex.Transaction} trx +// */ +// public rewriteExpenseGLEntries = async ( +// expenseId: number, +// trx?: Knex.Transaction, +// ) => { +// // Reverts the expense GL entries. +// await this.revertExpenseGLEntries(expenseId, trx); - /** - * Rewrites the expense GL entries. - * @param {number} tenantId - * @param {number} expenseId - * @param {Knex.Transaction} trx - */ - public rewriteExpenseGLEntries = async ( - tenantId: number, - expenseId: number, - trx?: Knex.Transaction - ) => { - // Reverts the expense GL entries. - await this.revertExpenseGLEntries(tenantId, expenseId, trx); - - // Writes the expense GL entries. - await this.writeExpenseGLEntries(tenantId, expenseId, trx); - }; -} +// // Writes the expense GL entries. +// await this.writeExpenseGLEntries(expenseId, trx); +// }; +// } diff --git a/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesSubscriber.ts b/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesSubscriber.ts deleted file mode 100644 index 4c69b7f04..000000000 --- a/packages/server-nest/src/modules/Expenses/subscribers/ExpenseGLEntriesSubscriber.ts +++ /dev/null @@ -1,117 +0,0 @@ -import { Inject, Service } from 'typedi'; -import events from '@/subscribers/events'; -import TenancyService from '@/services/Tenancy/TenancyService'; -import { - IExpenseCreatedPayload, - IExpenseEventDeletePayload, - IExpenseEventEditPayload, - IExpenseEventPublishedPayload, -} from '@/interfaces'; -import { ExpenseGLEntriesStorage } from './ExpenseGLEntriesStorage'; - -@Service() -export class ExpensesWriteGLSubscriber { - @Inject() - private tenancy: TenancyService; - - @Inject() - private expenseGLEntries: ExpenseGLEntriesStorage; - - /** - * Attaches events with handlers. - * @param bus - */ - public attach(bus) { - bus.subscribe( - events.expenses.onCreated, - this.handleWriteGLEntriesOnceCreated - ); - bus.subscribe( - events.expenses.onEdited, - this.handleRewriteGLEntriesOnceEdited - ); - bus.subscribe( - events.expenses.onDeleted, - this.handleRevertGLEntriesOnceDeleted - ); - bus.subscribe( - events.expenses.onPublished, - this.handleWriteGLEntriesOncePublished - ); - } - - /** - * Handles the writing journal entries once the expense created. - * @param {IExpenseCreatedPayload} payload - - */ - public handleWriteGLEntriesOnceCreated = async ({ - expense, - tenantId, - trx, - }: IExpenseCreatedPayload) => { - // In case expense published, write journal entries. - if (!expense.publishedAt) return; - - await this.expenseGLEntries.writeExpenseGLEntries( - tenantId, - expense.id, - trx - ); - }; - - /** - * Handle writing expense journal entries once the expense edited. - * @param {IExpenseEventEditPayload} payload - - */ - public handleRewriteGLEntriesOnceEdited = async ({ - expenseId, - tenantId, - expense, - authorizedUser, - trx, - }: IExpenseEventEditPayload) => { - // Cannot continue if the expense is not published. - if (!expense.publishedAt) return; - - await this.expenseGLEntries.rewriteExpenseGLEntries( - tenantId, - expense.id, - trx - ); - }; - - /** - * Reverts expense journal entries once the expense deleted. - * @param {IExpenseEventDeletePayload} payload - - */ - public handleRevertGLEntriesOnceDeleted = async ({ - expenseId, - tenantId, - trx, - }: IExpenseEventDeletePayload) => { - await this.expenseGLEntries.revertExpenseGLEntries( - tenantId, - expenseId, - trx - ); - }; - - /** - * Handles writing expense journal once the expense publish. - * @param {IExpenseEventPublishedPayload} payload - - */ - public handleWriteGLEntriesOncePublished = async ({ - tenantId, - expense, - trx, - }: IExpenseEventPublishedPayload) => { - // In case expense published, write journal entries. - if (!expense.publishedAt) return; - - await this.expenseGLEntries.rewriteExpenseGLEntries( - tenantId, - expense.id, - trx - ); - }; -} diff --git a/packages/server-nest/src/modules/Items/ItemsEntries.service.ts b/packages/server-nest/src/modules/Items/ItemsEntries.service.ts new file mode 100644 index 000000000..0e717356d --- /dev/null +++ b/packages/server-nest/src/modules/Items/ItemsEntries.service.ts @@ -0,0 +1,252 @@ +import { Knex } from 'knex'; +import { sumBy, difference, map } from 'lodash'; +import { Inject, Injectable } from '@nestjs/common'; +import { Item } from './models/Item'; +import { ItemEntry } from '../TransactionItemEntry/models/ItemEntry'; +import { ServiceError } from './ServiceError'; +import { IItemEntryDTO } from '../TransactionItemEntry/ItemEntry.types'; + +const ERRORS = { + ITEMS_NOT_FOUND: 'ITEMS_NOT_FOUND', + ENTRIES_IDS_NOT_FOUND: 'ENTRIES_IDS_NOT_FOUND', + NOT_PURCHASE_ABLE_ITEMS: 'NOT_PURCHASE_ABLE_ITEMS', + NOT_SELL_ABLE_ITEMS: 'NOT_SELL_ABLE_ITEMS', +}; + +@Injectable() +export class ItemsEntriesService { + /** + * @param {typeof Item} itemModel - Item model. + * @param {typeof ItemEntry} itemEntryModel - Item entry model. + */ + constructor( + @Inject(Item.name) private readonly itemModel: typeof Item, + @Inject(ItemEntry.name) private readonly itemEntryModel: typeof ItemEntry, + ) {} + + /** + * Retrieve the inventory items entries of the reference id and type. + * @param {string} referenceType - Reference type. + * @param {number} referenceId - Reference id. + * @return {Promise} + */ + public async getInventoryEntries( + referenceType: string, + referenceId: number, + ): Promise { + const itemsEntries = await this.itemEntryModel + .query() + .where('reference_type', referenceType) + .where('reference_id', referenceId); + + const inventoryItems = await this.itemModel + .query() + .whereIn('id', map(itemsEntries, 'itemId')) + .where('type', 'inventory'); + + const inventoryItemsIds = map(inventoryItems, 'id'); + + return itemsEntries.filter( + (itemEntry) => inventoryItemsIds.indexOf(itemEntry.itemId) !== -1, + ); + } + + /** + * Filter the given entries to inventory entries. + * @param {IItemEntry[]} entries - Items entries. + * @param {Knex.Transaction} trx - Knex transaction. + * @returns {Promise} + */ + public async filterInventoryEntries( + entries: ItemEntry[], + trx?: Knex.Transaction, + ): Promise { + const entriesItemsIds = entries.map((e) => e.itemId); + + const inventoryItems = await this.itemModel + .query(trx) + .whereIn('id', entriesItemsIds) + .where('type', 'inventory'); + + return entries.filter((entry) => + inventoryItems.some((item) => item.id === entry.itemId), + ); + } + + /** + * Validates the entries items ids. + * @param {IItemEntryDTO[]} itemEntries - Items entries. + * @returns {Promise} + */ + public async validateItemsIdsExistance(itemEntries: IItemEntryDTO[]) { + const itemsIds = itemEntries.map((e) => e.itemId); + + const foundItems = await this.itemModel.query().whereIn('id', itemsIds); + + const foundItemsIds = foundItems.map((item: Item) => item.id); + const notFoundItemsIds = difference(itemsIds, foundItemsIds); + + if (notFoundItemsIds.length > 0) { + throw new ServiceError(ERRORS.ITEMS_NOT_FOUND); + } + return foundItems; + } + + /** + * Validates the entries ids existance on the storage. + * @param {number} referenceId - + * @param {string} referenceType - + * @param {IItemEntryDTO[]} entries - + */ + public async validateEntriesIdsExistance( + referenceId: number, + referenceType: string, + billEntries: IItemEntryDTO[], + ) { + const entriesIds = billEntries + .filter((e: ItemEntry) => e.id) + .map((e: ItemEntry) => e.id); + + const storedEntries = await this.itemEntryModel + .query() + .whereIn('reference_id', [referenceId]) + .whereIn('reference_type', [referenceType]); + + const storedEntriesIds = storedEntries.map((entry) => entry.id); + const notFoundEntriesIds = difference(entriesIds, storedEntriesIds); + + if (notFoundEntriesIds.length > 0) { + throw new ServiceError(ERRORS.ENTRIES_IDS_NOT_FOUND); + } + } + + /** + * Validate the entries items that not purchase-able. + * @param {IItemEntryDTO[]} itemEntries - + */ + public async validateNonPurchasableEntriesItems( + itemEntries: IItemEntryDTO[], + ) { + const itemsIds = itemEntries.map((e: IItemEntryDTO) => e.itemId); + const purchasbleItems = await this.itemModel + .query() + .where('purchasable', true) + .whereIn('id', itemsIds); + + const purchasbleItemsIds = purchasbleItems.map((item: Item) => item.id); + const notPurchasableItems = difference(itemsIds, purchasbleItemsIds); + + if (notPurchasableItems.length > 0) { + throw new ServiceError(ERRORS.NOT_PURCHASE_ABLE_ITEMS); + } + } + + /** + * Validate the entries items that not sell-able. + * @param {IItemEntryDTO[]} itemEntries - + */ + public async validateNonSellableEntriesItems(itemEntries: IItemEntryDTO[]) { + const itemsIds = itemEntries.map((e: IItemEntryDTO) => e.itemId); + + const sellableItems = await this.itemModel + .query() + .where('sellable', true) + .whereIn('id', itemsIds); + + const sellableItemsIds = sellableItems.map((item: Item) => item.id); + const nonSellableItems = difference(itemsIds, sellableItemsIds); + + if (nonSellableItems.length > 0) { + throw new ServiceError(ERRORS.NOT_SELL_ABLE_ITEMS); + } + } + + /** + * Changes items quantity from the given items entries the new and old onces. + * @param {IItemEntry[]} entries - Items entries. + * @param {IItemEntry[]} oldEntries - Old items entries. + */ + public async changeItemsQuantity( + entries: ItemEntry[], + oldEntries?: ItemEntry[], + ): Promise { + const opers = []; + + // const diffEntries = entriesAmountDiff( + // entries, + // oldEntries, + // 'quantity', + // 'itemId', + // ); + // diffEntries.forEach((entry: ItemEntry) => { + // const changeQuantityOper = this.itemRepository.changeNumber( + // { id: entry.itemId, type: 'inventory' }, + // 'quantityOnHand', + // entry.quantity, + // ); + // opers.push(changeQuantityOper); + // }); + // await Promise.all(opers); + } + + /** + * Increment items quantity from the given items entries. + * @param {IItemEntry[]} entries - Items entries. + */ + public async incrementItemsEntries(entries: ItemEntry[]): Promise { + return this.changeItemsQuantity(entries); + } + + /** + * Decrement items quantity from the given items entries. + * @param {IItemEntry[]} entries - Items entries. + */ + public async decrementItemsQuantity(entries: ItemEntry[]): Promise { + // return this.changeItemsQuantity( + // entries.map((entry) => ({ + // ...entry, + // quantity: entry.quantity * -1, + // })), + // ); + } + + /** + * Sets the cost/sell accounts to the invoice entries. + */ + public setItemsEntriesDefaultAccounts() { + return async (entries: ItemEntry[]) => { + const entriesItemsIds = entries.map((e) => e.itemId); + const items = await this.itemModel.query().whereIn('id', entriesItemsIds); + + return entries.map((entry) => { + const item = items.find((i) => i.id === entry.itemId); + + return { + ...entry, + sellAccountId: entry.sellAccountId || item.sellAccountId, + ...(item.type === 'inventory' && { + costAccountId: entry.costAccountId || item.costAccountId, + }), + }; + }); + }; + } + + /** + * Retrieve the total items entries. + * @param {ItemEntry[]} entries - Items entries. + * @returns {number} + */ + public getTotalItemsEntries(entries: ItemEntry[]): number { + return sumBy(entries, (e) => ItemEntry.calcAmount(e)); + } + + /** + * Retrieve the non-zero tax items entries. + * @param {ItemEntry[]} entries - + * @returns {ItemEntry[]} + */ + public getNonZeroEntries(entries: ItemEntry[]): ItemEntry[] { + return entries.filter((e) => e.taxRate > 0); + } +} diff --git a/packages/server-nest/src/modules/Items/ItemsEntriesService.ts b/packages/server-nest/src/modules/Items/ItemsEntriesService.ts deleted file mode 100644 index e69de29bb..000000000 diff --git a/packages/server-nest/src/modules/Items/ServiceError.ts b/packages/server-nest/src/modules/Items/ServiceError.ts index 1c2efd9bc..86f3c1010 100644 --- a/packages/server-nest/src/modules/Items/ServiceError.ts +++ b/packages/server-nest/src/modules/Items/ServiceError.ts @@ -1,13 +1,19 @@ +import { HttpStatus } from '@nestjs/common'; -export class ServiceError { - errorType: string; - message: string; - payload: any; - - constructor(errorType: string, message?: string, payload?: any) { - this.errorType = errorType; - this.message = message || null; - - this.payload = payload; - } - } \ No newline at end of file +export class ServiceError extends Error { + errorType: string; + message: string; + payload: any; + + constructor(errorType: string, message?: string, payload?: any) { + super(message); + + this.errorType = errorType; + this.message = message || null; + this.payload = payload; + } + + getStatus(): HttpStatus { + return HttpStatus.INTERNAL_SERVER_ERROR; + } +} diff --git a/packages/server-nest/src/modules/Items/models/Item.ts b/packages/server-nest/src/modules/Items/models/Item.ts index 6ee459981..93f9733e6 100644 --- a/packages/server-nest/src/modules/Items/models/Item.ts +++ b/packages/server-nest/src/modules/Items/models/Item.ts @@ -21,6 +21,13 @@ export class Item extends BaseModel { public readonly costAccountId: number; public readonly inventoryAccountId: number; public readonly categoryId: number; + public readonly pictureUri: string; + public readonly sellAccountId: number; + public readonly sellDescription: string; + public readonly purchaseDescription: string; + public readonly landedCost: boolean; + public readonly note: string; + public readonly userId: number; public readonly warehouse!: Warehouse; diff --git a/packages/server-nest/src/modules/PdfTemplate/BrandingTemplateDTOTransformer.ts b/packages/server-nest/src/modules/PdfTemplate/BrandingTemplateDTOTransformer.ts index 01ef57a91..277f2255e 100644 --- a/packages/server-nest/src/modules/PdfTemplate/BrandingTemplateDTOTransformer.ts +++ b/packages/server-nest/src/modules/PdfTemplate/BrandingTemplateDTOTransformer.ts @@ -1,37 +1,40 @@ -// import { Inject, Service } from 'typedi'; -// import { isNil } from 'lodash'; -// import HasTenancyService from '../Tenancy/TenancyService'; +import { Inject, Injectable } from '@nestjs/common'; +import { isNil } from 'lodash'; +import { PdfTemplateModel } from './models/PdfTemplate'; -// @Service() -// export class BrandingTemplateDTOTransformer { -// @Inject() -// private tenancy: HasTenancyService; +@Injectable() +export class BrandingTemplateDTOTransformer { + /** + * @param {PdfTemplateModel} - Pdf template model. + */ + constructor( + @Inject(PdfTemplateModel.name) + private readonly pdfTemplate: typeof PdfTemplateModel, + ) {} -// /** -// * Associates the default branding template id. -// * @param {number} tenantId -// * @param {string} resource -// * @param {Record} object -// * @param {string} attributeName -// * @returns -// */ -// public assocDefaultBrandingTemplate = -// (tenantId: number, resource: string) => -// async (object: Record) => { -// const { PdfTemplate } = this.tenancy.models(tenantId); -// const attributeName = 'pdfTemplateId'; + /** + * Associates the default branding template id. + * @param {string} resource - Resource name. + * @param {Record} object - + * @param {string} attributeName + * @returns + */ + public assocDefaultBrandingTemplate = + (resource: string) => async (object: Record) => { + const attributeName = 'pdfTemplateId'; -// const defaultTemplate = await PdfTemplate.query() -// .modify('default') -// .findOne({ resource }); + const defaultTemplate = await this.pdfTemplate + .query() + .modify('default') + .findOne({ resource }); -// // If the default template is not found OR the given object has no defined template id. -// if (!defaultTemplate || !isNil(object[attributeName])) { -// return object; -// } -// return { -// ...object, -// [attributeName]: defaultTemplate.id, -// }; -// }; -// } + // If the default template is not found OR the given object has no defined template id. + if (!defaultTemplate || !isNil(object[attributeName])) { + return object; + } + return { + ...object, + [attributeName]: defaultTemplate.id, + }; + }; +} diff --git a/packages/server-nest/src/modules/SaleEstimates/SaleEstimates.application.ts b/packages/server-nest/src/modules/SaleEstimates/SaleEstimates.application.ts new file mode 100644 index 000000000..8c2fec49b --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/SaleEstimates.application.ts @@ -0,0 +1,178 @@ +import { CreateSaleEstimate } from './commands/CreateSaleEstimate.service'; +import { + // IFilterMeta, + // IPaginationMeta, + // IPaymentReceivedSmsDetails, + ISaleEstimateDTO, + // ISalesEstimatesFilter, + // SaleEstimateMailOptions, + // SaleEstimateMailOptionsDTO, +} from './types/SaleEstimates.types'; +import { EditSaleEstimate } from './commands/EditSaleEstimate.service'; +import { DeleteSaleEstimate } from './commands/DeleteSaleEstimate.service'; +import { GetSaleEstimate } from './queries/GetSaleEstimate.service'; +// import { GetSaleEstimates } from './queries/GetSaleEstimates'; +import { DeliverSaleEstimateService } from './commands/DeliverSaleEstimate.service'; +import { ApproveSaleEstimateService } from './commands/ApproveSaleEstimate.service'; +import { RejectSaleEstimateService } from './commands/RejectSaleEstimate.service'; +// import { SaleEstimateNotifyBySms } from './commands/SaleEstimateSmsNotify'; +// import { SaleEstimatesPdf } from './queries/SaleEstimatesPdf'; +// import { SendSaleEstimateMail } from './commands/SendSaleEstimateMail'; +import { GetSaleEstimateState } from './queries/GetSaleEstimateState.service'; +import { Injectable } from '@nestjs/common'; +import { IFilterMeta, IPaginationMeta } from '@/interfaces/Model'; + +@Injectable() +export class SaleEstimatesApplication { + constructor( + private readonly createSaleEstimateService: CreateSaleEstimate, + private readonly editSaleEstimateService: EditSaleEstimate, + private readonly deleteSaleEstimateService: DeleteSaleEstimate, + private readonly getSaleEstimateService: GetSaleEstimate, + // private readonly getSaleEstimatesService: GetSaleEstimates, + private readonly deliverSaleEstimateService: DeliverSaleEstimateService, + private readonly approveSaleEstimateService: ApproveSaleEstimateService, + private readonly rejectSaleEstimateService: RejectSaleEstimateService, + // private readonly saleEstimateNotifyBySmsService: SaleEstimateNotifyBySms, + // private readonly saleEstimatesPdfService: SaleEstimatesPdf, + // private readonly sendEstimateMailService: SendSaleEstimateMail, + private readonly getSaleEstimateStateService: GetSaleEstimateState, + ) {} + + /** + * Create a sale estimate. + * @param {EstimateDTO} estimate - Estimate DTO. + * @return {Promise} + */ + public createSaleEstimate(estimateDTO: ISaleEstimateDTO) { + return this.createSaleEstimateService.createEstimate(estimateDTO); + } + + /** + * Edit the given sale estimate. + * @param {number} estimateId - Sale estimate ID. + * @param {EstimateDTO} estimate - Estimate DTO. + * @return {Promise} + */ + public editSaleEstimate(estimateId: number, estimateDTO: ISaleEstimateDTO) { + return this.editSaleEstimateService.editEstimate(estimateId, estimateDTO); + } + + /** + * Deletes the given sale estimate. + * @param {number} estimateId - + * @return {Promise} + */ + public deleteSaleEstimate(estimateId: number) { + return this.deleteSaleEstimateService.deleteEstimate(estimateId); + } + + /** + * Retrieves the given sale estimate. + * @param {number} estimateId - Sale estimate ID. + */ + public getSaleEstimate(estimateId: number) { + return this.getSaleEstimateService.getEstimate(estimateId); + } + + /** + * Retrieves the sale estimate. + * @param {ISalesEstimatesFilter} filterDTO - Sales estimates filter DTO. + * @returns + */ + // public getSaleEstimates(filterDTO: ISalesEstimatesFilter) { + // return this.getSaleEstimatesService.getEstimates(filterDTO); + // } + + /** + * Deliver the given sale estimate. + * @param {number} saleEstimateId + * @returns {Promise} + */ + public deliverSaleEstimate(saleEstimateId: number) { + return this.deliverSaleEstimateService.deliverSaleEstimate(saleEstimateId); + } + + /** + * Approve the given sale estimate. + * @param {number} saleEstimateId - Sale estimate ID. + * @returns {Promise} + */ + public approveSaleEstimate(saleEstimateId: number) { + return this.approveSaleEstimateService.approveSaleEstimate(saleEstimateId); + } + + /** + * Mark the sale estimate as rejected from the customer. + * @param {number} saleEstimateId + */ + public async rejectSaleEstimate(saleEstimateId: number) { + return this.rejectSaleEstimateService.rejectSaleEstimate(saleEstimateId); + } + + /** + * Notify the customer of the given sale estimate by SMS. + * @param {number} saleEstimateId - Sale estimate ID. + * @returns {Promise} + */ + public notifySaleEstimateBySms = async (saleEstimateId: number) => { + // return this.saleEstimateNotifyBySmsService.notifyBySms( + // saleEstimateId, + // ); + }; + + /** + * Retrieve the SMS details of the given payment receive transaction. + * @param {number} saleEstimateId - Sale estimate ID. + * @returns {Promise} + */ + public getSaleEstimateSmsDetails = (saleEstimateId: number) => { + // return this.saleEstimateNotifyBySmsService.smsDetails( + // saleEstimateId, + // ); + }; + + /** + * Retrieve the PDF content of the given sale estimate. + * @param {number} saleEstimateId - Sale estimate ID. + * @returns + */ + public getSaleEstimatePdf(saleEstimateId: number) { + // return this.saleEstimatesPdfService.getSaleEstimatePdf( + // saleEstimateId, + // ); + } + + /** + * Send the reminder mail of the given sale estimate. + * @param {number} saleEstimateId - Sale estimate ID. + * @returns {Promise} + */ + public sendSaleEstimateMail() // saleEstimateId: number, + // saleEstimateMailOpts: SaleEstimateMailOptionsDTO, + { + // return this.sendEstimateMailService.triggerMail( + // saleEstimateId, + // saleEstimateMailOpts, + // ); + } + + /** + * Retrieves the default mail options of the given sale estimate. + * @param {number} saleEstimateId + * @returns {Promise} + */ + public getSaleEstimateMail(saleEstimateId: number) { + // return this.sendEstimateMailService.getMailOptions( + // saleEstimateId, + // ); + } + + /** + * Retrieves the current state of the sale estimate. + * @returns {Promise} - A promise resolving to the sale estimate state. + */ + public getSaleEstimateState() { + return this.getSaleEstimateStateService.getSaleEstimateState(); + } +} diff --git a/packages/server-nest/src/modules/SaleEstimates/SaleEstimates.controller.ts b/packages/server-nest/src/modules/SaleEstimates/SaleEstimates.controller.ts new file mode 100644 index 000000000..5f2a32c05 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/SaleEstimates.controller.ts @@ -0,0 +1,132 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseIntPipe, + Post, + Put, + Query, +} from '@nestjs/common'; +import { SaleEstimatesApplication } from './SaleEstimates.application'; +import { + ISaleEstimateDTO, + // ISalesEstimatesFilter, + // SaleEstimateMailOptionsDTO, +} from './types/SaleEstimates.types'; +import { SaleEstimate } from './models/SaleEstimate'; +import { PublicRoute } from '../Auth/Jwt.guard'; + +@Controller('sales/estimates') +@PublicRoute() +export class SaleEstimatesController { + /** + * @param {SaleEstimatesApplication} saleEstimatesApplication - Sale estimates application. + */ + constructor( + private readonly saleEstimatesApplication: SaleEstimatesApplication, + ) {} + + @Post() + public createSaleEstimate( + @Body() estimateDTO: ISaleEstimateDTO, + ): Promise { + return this.saleEstimatesApplication.createSaleEstimate(estimateDTO); + } + + @Put(':id') + public editSaleEstimate( + @Param('id', ParseIntPipe) estimateId: number, + @Body() estimateDTO: ISaleEstimateDTO, + ): Promise { + return this.saleEstimatesApplication.editSaleEstimate( + estimateId, + estimateDTO, + ); + } + + @Delete(':id') + public deleteSaleEstimate( + @Param('id', ParseIntPipe) estimateId: number, + ): Promise { + return this.saleEstimatesApplication.deleteSaleEstimate(estimateId); + } + + @Get(':id') + public getSaleEstimate(@Param('id', ParseIntPipe) estimateId: number) { + return this.saleEstimatesApplication.getSaleEstimate(estimateId); + } + + // @Get() + // public getSaleEstimates(@Query() filterDTO: ISalesEstimatesFilter) { + // return this.saleEstimatesApplication.getSaleEstimates(filterDTO); + // } + + @Post(':id/deliver') + public deliverSaleEstimate( + @Param('id', ParseIntPipe) saleEstimateId: number, + ): Promise { + return this.saleEstimatesApplication.deliverSaleEstimate(saleEstimateId); + } + + @Post(':id/approve') + public approveSaleEstimate( + @Param('id', ParseIntPipe) saleEstimateId: number, + ): Promise { + return this.saleEstimatesApplication.approveSaleEstimate(saleEstimateId); + } + + @Post(':id/reject') + public rejectSaleEstimate( + @Param('id', ParseIntPipe) saleEstimateId: number, + ): Promise { + return this.saleEstimatesApplication.rejectSaleEstimate(saleEstimateId); + } + + @Post(':id/notify-sms') + public notifySaleEstimateBySms( + @Param('id', ParseIntPipe) saleEstimateId: number, + ) { + return this.saleEstimatesApplication.notifySaleEstimateBySms( + saleEstimateId, + ); + } + + @Get(':id/sms-details') + public getSaleEstimateSmsDetails( + @Param('id', ParseIntPipe) saleEstimateId: number, + ) { + return this.saleEstimatesApplication.getSaleEstimateSmsDetails( + saleEstimateId, + ); + } + + @Get(':id/pdf') + public getSaleEstimatePdf(@Param('id', ParseIntPipe) saleEstimateId: number) { + return this.saleEstimatesApplication.getSaleEstimatePdf(saleEstimateId); + } + + // @Post(':id/mail') + // public sendSaleEstimateMail( + // @Param('id', ParseIntPipe) saleEstimateId: number, + // @Body() mailOptions: SaleEstimateMailOptionsDTO, + // ) { + // return this.saleEstimatesApplication.sendSaleEstimateMail( + // saleEstimateId, + // mailOptions, + // ); + // } + + @Get(':id/mail') + public getSaleEstimateMail( + @Param('id', ParseIntPipe) saleEstimateId: number, + ) { + return this.saleEstimatesApplication.getSaleEstimateMail(saleEstimateId); + } + + @Get('state') + public getSaleEstimateState() { + return this.saleEstimatesApplication.getSaleEstimateState(); + } +} diff --git a/packages/server-nest/src/modules/SaleEstimates/SaleEstimates.module.ts b/packages/server-nest/src/modules/SaleEstimates/SaleEstimates.module.ts new file mode 100644 index 000000000..b70bf5bd1 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/SaleEstimates.module.ts @@ -0,0 +1,59 @@ +import { Module } from '@nestjs/common'; +import { TenancyContext } from '../Tenancy/TenancyContext.service'; +import { TenancyDatabaseModule } from '../Tenancy/TenancyDB/TenancyDB.module'; +import { TransformerInjectable } from '../Transformer/TransformerInjectable.service'; +import { ApproveSaleEstimateService } from './commands/ApproveSaleEstimate.service'; +import { ConvertSaleEstimate } from './commands/ConvetSaleEstimate.service'; +import { CreateSaleEstimate } from './commands/CreateSaleEstimate.service'; +import { DeliverSaleEstimateService } from './commands/DeliverSaleEstimate.service'; +import { EditSaleEstimate } from './commands/EditSaleEstimate.service'; +import { RejectSaleEstimateService } from './commands/RejectSaleEstimate.service'; +import { SaleEstimateValidators } from './commands/SaleEstimateValidators.service'; +import { SaleEstimatesController } from './SaleEstimates.controller'; +import { ItemsEntriesService } from '../Items/ItemsEntries.service'; +import { SaleEstimateDTOTransformer } from './commands/SaleEstimateDTOTransformer.service'; +import { BranchTransactionDTOTransformer } from '../Branches/integrations/BranchTransactionDTOTransform'; +import { BranchesSettingsService } from '../Branches/BranchesSettings'; +import { WarehouseTransactionDTOTransform } from '../Warehouses/Integrations/WarehouseTransactionDTOTransform'; +import { WarehousesSettings } from '../Warehouses/WarehousesSettings'; +import { SaleEstimateIncrement } from './commands/SaleEstimateIncrement.service'; +import { AutoIncrementOrdersService } from '../AutoIncrementOrders/AutoIncrementOrders.service'; +import { BrandingTemplateDTOTransformer } from '../PdfTemplate/BrandingTemplateDTOTransformer'; +import { SaleEstimatesApplication } from './SaleEstimates.application'; +import { DeleteSaleEstimate } from './commands/DeleteSaleEstimate.service'; +import { GetSaleEstimate } from './queries/GetSaleEstimate.service'; +import { GetSaleEstimateState } from './queries/GetSaleEstimateState.service'; +// import { SaleEstimateNotifyBySms } from './commands/SaleEstimateSmsNotify'; +// import { SendSaleEstimateMail } from './commands/SendSaleEstimateMail'; +// +@Module({ + imports: [TenancyDatabaseModule], + controllers: [SaleEstimatesController], + providers: [ + AutoIncrementOrdersService, + BrandingTemplateDTOTransformer, + SaleEstimateIncrement, + CreateSaleEstimate, + ConvertSaleEstimate, + EditSaleEstimate, + DeleteSaleEstimate, + GetSaleEstimate, + GetSaleEstimateState, + ApproveSaleEstimateService, + DeliverSaleEstimateService, + RejectSaleEstimateService, + SaleEstimateValidators, + ItemsEntriesService, + BranchesSettingsService, + WarehousesSettings, + BranchTransactionDTOTransformer, + WarehouseTransactionDTOTransform, + SaleEstimateDTOTransformer, + TenancyContext, + TransformerInjectable, + SaleEstimatesApplication + // SaleEstimateNotifyBySms, + // SendSaleEstimateMail,p + ], +}) +export class SaleEstimatesModule {} diff --git a/packages/server-nest/src/modules/SaleEstimates/SaleEstimatesExportable.ts b/packages/server-nest/src/modules/SaleEstimates/SaleEstimatesExportable.ts new file mode 100644 index 000000000..889e7ca7a --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/SaleEstimatesExportable.ts @@ -0,0 +1,35 @@ +// import { Inject, Service } from 'typedi'; +// import { ISalesInvoicesFilter } from '@/interfaces'; +// import { Exportable } from '@/services/Export/Exportable'; +// import { SaleEstimatesApplication } from './SaleEstimates.application'; +// import { EXPORT_SIZE_LIMIT } from '@/services/Export/constants'; + +// @Service() +// export class SaleEstimatesExportable extends Exportable { +// @Inject() +// private saleEstimatesApplication: SaleEstimatesApplication; + +// /** +// * Retrieves the accounts data to exportable sheet. +// * @param {number} tenantId +// * @returns +// */ +// public exportable(tenantId: number, query: ISalesInvoicesFilter) { +// const filterQuery = (query) => { +// query.withGraphFetched('branch'); +// query.withGraphFetched('warehouse'); +// }; +// const parsedQuery = { +// sortOrder: 'desc', +// columnSortBy: 'created_at', +// ...query, +// page: 1, +// pageSize: EXPORT_SIZE_LIMIT, +// filterQuery, +// } as ISalesInvoicesFilter; + +// return this.saleEstimatesApplication +// .getSaleEstimates(tenantId, parsedQuery) +// .then((output) => output.salesEstimates); +// } +// } diff --git a/packages/server-nest/src/modules/SaleEstimates/SaleEstimatesImportable.ts b/packages/server-nest/src/modules/SaleEstimates/SaleEstimatesImportable.ts new file mode 100644 index 000000000..d5ed0b6c9 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/SaleEstimatesImportable.ts @@ -0,0 +1,45 @@ +// import { Inject, Service } from 'typedi'; +// import { Knex } from 'knex'; +// import { ISaleEstimateDTO } from '@/interfaces'; +// import { CreateSaleEstimate } from './commands/CreateSaleEstimate.service'; +// import { Importable } from '@/services/Import/Importable'; +// import { SaleEstimatesSampleData } from './constants'; + +// @Service() +// export class SaleEstimatesImportable extends Importable { +// @Inject() +// private createEstimateService: CreateSaleEstimate; + +// /** +// * Importing to account service. +// * @param {number} tenantId +// * @param {IAccountCreateDTO} createAccountDTO +// * @returns +// */ +// public importable( +// tenantId: number, +// createEstimateDTO: ISaleEstimateDTO, +// trx?: Knex.Transaction +// ) { +// return this.createEstimateService.createEstimate( +// tenantId, +// createEstimateDTO, +// trx +// ); +// } + +// /** +// * Concurrrency controlling of the importing process. +// * @returns {number} +// */ +// public get concurrency() { +// return 1; +// } + +// /** +// * Retrieves the sample data that used to download accounts sample sheet. +// */ +// public sampleData(): any[] { +// return SaleEstimatesSampleData; +// } +// } diff --git a/packages/server-nest/src/modules/SaleEstimates/commands/ApproveSaleEstimate.service.ts b/packages/server-nest/src/modules/SaleEstimates/commands/ApproveSaleEstimate.service.ts new file mode 100644 index 000000000..107d846b9 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/commands/ApproveSaleEstimate.service.ts @@ -0,0 +1,68 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + ISaleEstimateApprovedEvent, + ISaleEstimateApprovingEvent, +} from '../types/SaleEstimates.types'; +import { ERRORS } from '../constants'; +import { Knex } from 'knex'; +import moment from 'moment'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { SaleEstimate } from '../models/SaleEstimate'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; +import { ServiceError } from '@/modules/Items/ServiceError'; + +@Injectable() +export class ApproveSaleEstimateService { + constructor( + @Inject(SaleEstimate.name) + private saleEstimateModel: typeof SaleEstimate, + private uow: UnitOfWork, + private eventPublisher: EventEmitter2, + ) {} + + /** + * Mark the sale estimate as approved from the customer. + * @param {number} saleEstimateId + * @return {Promise} + */ + public async approveSaleEstimate(saleEstimateId: number): Promise { + // Retrieve details of the given sale estimate id. + const oldSaleEstimate = await this.saleEstimateModel + .query() + .findById(saleEstimateId) + .throwIfNotFound(); + + // Throws error in case the sale estimate still not delivered to customer. + if (!oldSaleEstimate.isDelivered) { + throw new ServiceError(ERRORS.SALE_ESTIMATE_NOT_DELIVERED); + } + // Throws error in case the sale estimate already approved. + if (oldSaleEstimate.isApproved) { + throw new ServiceError(ERRORS.SALE_ESTIMATE_ALREADY_APPROVED); + } + // Triggers `onSaleEstimateApproving` event. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onSaleEstimateApproving` event. + await this.eventPublisher.emitAsync(events.saleEstimate.onApproving, { + trx, + oldSaleEstimate, + } as ISaleEstimateApprovingEvent); + + // Update estimate as approved. + const saleEstimate = await this.saleEstimateModel + .query(trx) + .where('id', saleEstimateId) + .patchAndFetch({ + approvedAt: moment().toMySqlDateTime(), + rejectedAt: null, + }); + // Triggers `onSaleEstimateApproved` event. + await this.eventPublisher.emitAsync(events.saleEstimate.onApproved, { + trx, + oldSaleEstimate, + saleEstimate, + } as ISaleEstimateApprovedEvent); + }); + } +} diff --git a/packages/server-nest/src/modules/SaleEstimates/commands/ConvetSaleEstimate.service.ts b/packages/server-nest/src/modules/SaleEstimates/commands/ConvetSaleEstimate.service.ts new file mode 100644 index 000000000..b6abd3d99 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/commands/ConvetSaleEstimate.service.ts @@ -0,0 +1,44 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Knex } from 'knex'; +import moment from 'moment'; +import { SaleEstimate } from '../models/SaleEstimate'; +import { events } from '@/common/events/events'; + +@Injectable() +export class ConvertSaleEstimate { + constructor( + private readonly eventPublisher: EventEmitter2, + + @Inject(SaleEstimate.name) + private readonly saleEstimateModel: typeof SaleEstimate, + ) {} + + /** + * Converts estimate to invoice. + * @param {number} estimateId - + * @return {Promise} + */ + public async convertEstimateToInvoice( + estimateId: number, + invoiceId: number, + trx?: Knex.Transaction + ): Promise { + // Retrieve details of the given sale estimate. + const saleEstimate = await this.saleEstimateModel.query() + .findById(estimateId) + .throwIfNotFound(); + + // Marks the estimate as converted from the givne invoice. + await this.saleEstimateModel.query(trx).where('id', estimateId).patch({ + convertedToInvoiceId: invoiceId, + convertedToInvoiceAt: moment().toMySqlDateTime(), + }); + + // Triggers `onSaleEstimateConvertedToInvoice` event. + await this.eventPublisher.emitAsync( + events.saleEstimate.onConvertedToInvoice, + {} + ); + } +} diff --git a/packages/server-nest/src/modules/SaleEstimates/commands/CreateSaleEstimate.service.ts b/packages/server-nest/src/modules/SaleEstimates/commands/CreateSaleEstimate.service.ts new file mode 100644 index 000000000..c7221e423 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/commands/CreateSaleEstimate.service.ts @@ -0,0 +1,86 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { + ISaleEstimateCreatedPayload, + ISaleEstimateCreatingPayload, + ISaleEstimateDTO, +} from '../types/SaleEstimates.types'; +import { SaleEstimateDTOTransformer } from './SaleEstimateDTOTransformer.service'; +import { SaleEstimateValidators } from './SaleEstimateValidators.service'; +import { SaleEstimate } from '../models/SaleEstimate'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { events } from '@/common/events/events'; +import { ItemsEntriesService } from '@/modules/Items/ItemsEntries.service'; +import { Customer } from '@/modules/Customers/models/Customer'; + +@Injectable() +export class CreateSaleEstimate { + constructor( + @Inject(SaleEstimate.name) private saleEstimateModel: typeof SaleEstimate, + @Inject(Customer.name) private customerModel: typeof Customer, + private itemsEntriesService: ItemsEntriesService, + private eventPublisher: EventEmitter2, + private uow: UnitOfWork, + private transformerDTO: SaleEstimateDTOTransformer, + private validators: SaleEstimateValidators, + ) {} + + /** + * Creates a new estimate with associated entries. + * @param {ISaleEstimateDTO} estimateDTO - Sale estimate DTO object. + * @return {Promise} + */ + public async createEstimate( + estimateDTO: ISaleEstimateDTO, + trx?: Knex.Transaction, + ): Promise { + // Retrieve the given customer or throw not found service error. + const customer = await this.customerModel + .query() + .findById(estimateDTO.customerId) + .throwIfNotFound(); + + // Transform DTO object to model object. + const estimateObj = await this.transformerDTO.transformDTOToModel( + estimateDTO, + customer, + ); + // Validate estimate number uniquiness on the storage. + await this.validators.validateEstimateNumberExistance( + estimateObj.estimateNumber, + ); + // Validate items IDs existance on the storage. + await this.itemsEntriesService.validateItemsIdsExistance( + estimateDTO.entries, + ); + // Validate non-sellable items. + await this.itemsEntriesService.validateNonSellableEntriesItems( + estimateDTO.entries, + ); + // Creates a sale estimate transaction with associated transactions as UOW. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onSaleEstimateCreating` event. + await this.eventPublisher.emitAsync(events.saleEstimate.onCreating, { + estimateDTO, + trx, + } as ISaleEstimateCreatingPayload); + + // Upsert the sale estimate graph to the storage. + const saleEstimate = await this.saleEstimateModel + .query(trx) + .upsertGraphAndFetch({ + ...estimateObj, + }); + // Triggers `onSaleEstimateCreated` event. + await this.eventPublisher.emitAsync(events.saleEstimate.onCreated, { + saleEstimate, + saleEstimateId: saleEstimate.id, + saleEstimateDTO: estimateDTO, + trx, + } as ISaleEstimateCreatedPayload); + + return saleEstimate; + }, trx); + } +} diff --git a/packages/server-nest/src/modules/SaleEstimates/commands/DeleteSaleEstimate.service.ts b/packages/server-nest/src/modules/SaleEstimates/commands/DeleteSaleEstimate.service.ts new file mode 100644 index 000000000..3ab7f8da7 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/commands/DeleteSaleEstimate.service.ts @@ -0,0 +1,71 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { + ISaleEstimateDeletedPayload, + ISaleEstimateDeletingPayload, +} from '../types/SaleEstimates.types'; +import { ERRORS } from '../constants'; +import { Knex } from 'knex'; +import { SaleEstimate } from '../models/SaleEstimate'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { ItemEntry } from '@/modules/TransactionItemEntry/models/ItemEntry'; + +@Injectable() +export class DeleteSaleEstimate { + constructor( + @Inject(SaleEstimate.name) + private readonly saleEstimateModel: typeof SaleEstimate, + + @Inject(ItemEntry.name) + private readonly itemEntryModel: typeof ItemEntry, + + private readonly eventPublisher: EventEmitter2, + private readonly uow: UnitOfWork, + ) {} + + /** + * Deletes the given estimate id with associated entries. + * @async + * @param {number} estimateId + * @return {Promise} + */ + public async deleteEstimate(estimateId: number): Promise { + // Retrieve sale estimate or throw not found service error. + const oldSaleEstimate = await this.saleEstimateModel + .query() + .findById(estimateId) + .throwIfNotFound(); + + // Throw error if the sale estimate converted to sale invoice. + if (oldSaleEstimate.convertedToInvoiceId) { + throw new ServiceError(ERRORS.SALE_ESTIMATE_CONVERTED_TO_INVOICE); + } + // Updates the estimate with associated transactions under UOW enivrement. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onSaleEstimatedDeleting` event. + await this.eventPublisher.emitAsync(events.saleEstimate.onDeleting, { + trx, + oldSaleEstimate, + } as ISaleEstimateDeletingPayload); + + // Delete sale estimate entries. + await this.itemEntryModel + .query(trx) + .where('reference_id', estimateId) + .where('reference_type', 'SaleEstimate') + .delete(); + + // Delete sale estimate transaction. + await this.saleEstimateModel.query(trx).where('id', estimateId).delete(); + + // Triggers `onSaleEstimatedDeleted` event. + await this.eventPublisher.emitAsync(events.saleEstimate.onDeleted, { + saleEstimateId: estimateId, + oldSaleEstimate, + trx, + } as ISaleEstimateDeletedPayload); + }); + } +} diff --git a/packages/server-nest/src/modules/SaleEstimates/commands/DeliverSaleEstimate.service.ts b/packages/server-nest/src/modules/SaleEstimates/commands/DeliverSaleEstimate.service.ts new file mode 100644 index 000000000..c0c05cf1e --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/commands/DeliverSaleEstimate.service.ts @@ -0,0 +1,63 @@ +import { Knex } from 'knex'; +import moment from 'moment'; +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { + ISaleEstimateEventDeliveredPayload, + ISaleEstimateEventDeliveringPayload, +} from '../types/SaleEstimates.types'; +import { ERRORS } from '../constants'; +import { SaleEstimate } from '../models/SaleEstimate'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { events } from '@/common/events/events'; +import { ServiceError } from '@/modules/Items/ServiceError'; + +@Injectable() +export class DeliverSaleEstimateService { + constructor( + @Inject(SaleEstimate.name) + private readonly saleEstimateModel: typeof SaleEstimate, + private readonly eventPublisher: EventEmitter2, + private readonly uow: UnitOfWork, + ) {} + + /** + * Mark the sale estimate as delivered. + * @param {number} saleEstimateId - Sale estimate id. + */ + public async deliverSaleEstimate(saleEstimateId: number): Promise { + // Retrieve details of the given sale estimate id. + const oldSaleEstimate = await this.saleEstimateModel + .query() + .findById(saleEstimateId) + .throwIfNotFound(); + + // Throws error in case the sale estimate already published. + if (oldSaleEstimate.isDelivered) { + throw new ServiceError(ERRORS.SALE_ESTIMATE_ALREADY_DELIVERED); + } + + // Updates the sale estimate transaction with assocaited transactions + // under UOW envirement. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Triggers `onSaleEstimateDelivering` event. + await this.eventPublisher.emitAsync(events.saleEstimate.onDelivering, { + oldSaleEstimate, + trx, + } as ISaleEstimateEventDeliveringPayload); + + // Record the delivered at on the storage. + const saleEstimate = await this.saleEstimateModel + .query(trx) + .patchAndFetchById(saleEstimateId, { + deliveredAt: moment().toMySqlDateTime(), + }); + + // Triggers `onSaleEstimateDelivered` event. + await this.eventPublisher.emitAsync(events.saleEstimate.onDelivered, { + saleEstimate, + trx, + } as ISaleEstimateEventDeliveredPayload); + }); + } +} diff --git a/packages/server-nest/src/modules/SaleEstimates/commands/EditSaleEstimate.service.ts b/packages/server-nest/src/modules/SaleEstimates/commands/EditSaleEstimate.service.ts new file mode 100644 index 000000000..7d9d1fb4c --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/commands/EditSaleEstimate.service.ts @@ -0,0 +1,115 @@ +import { Knex } from 'knex'; +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { + ISaleEstimateDTO, + ISaleEstimateEditedPayload, + ISaleEstimateEditingPayload, +} from '../types/SaleEstimates.types'; +import { SaleEstimateValidators } from './SaleEstimateValidators.service'; +import { SaleEstimateDTOTransformer } from './SaleEstimateDTOTransformer.service'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { events } from '@/common/events/events'; +import { SaleEstimate } from '../models/SaleEstimate'; +import { ItemsEntriesService } from '@/modules/Items/ItemsEntries.service'; +import { Customer } from '@/modules/Customers/models/Customer'; + +@Injectable() +export class EditSaleEstimate { + constructor( + private readonly validators: SaleEstimateValidators, + private readonly itemsEntriesService: ItemsEntriesService, + private readonly eventPublisher: EventEmitter2, + private readonly uow: UnitOfWork, + private readonly transformerDTO: SaleEstimateDTOTransformer, + + @Inject(SaleEstimate.name) + private readonly saleEstimateModel: typeof SaleEstimate, + + @Inject(Customer.name) + private readonly customerModel: typeof Customer, + ) {} + + /** + * Edit details of the given estimate with associated entries. + * @async + * @param {Integer} estimateId + * @param {EstimateDTO} estimate + * @return {Promise} + */ + public async editEstimate( + estimateId: number, + estimateDTO: ISaleEstimateDTO, + ): Promise { + // Retrieve details of the given sale estimate id. + const oldSaleEstimate = await this.saleEstimateModel + .query() + .findById(estimateId); + + // Validates the given estimate existance. + this.validators.validateEstimateExistance(oldSaleEstimate); + + // Retrieve the given customer or throw not found service error. + const customer = await this.customerModel + .query() + .findById(estimateDTO.customerId) + .throwIfNotFound(); + + // Transform DTO object to model object. + const estimateObj = await this.transformerDTO.transformDTOToModel( + estimateDTO, + customer, + oldSaleEstimate, + ); + // Validate estimate number uniquiness on the storage. + if (estimateDTO.estimateNumber) { + await this.validators.validateEstimateNumberExistance( + estimateDTO.estimateNumber, + estimateId, + ); + } + // Validate sale estimate entries existance. + await this.itemsEntriesService.validateEntriesIdsExistance( + estimateId, + 'SaleEstimate', + estimateDTO.entries, + ); + // Validate items IDs existance on the storage. + await this.itemsEntriesService.validateItemsIdsExistance( + estimateDTO.entries, + ); + // Validate non-sellable items. + await this.itemsEntriesService.validateNonSellableEntriesItems( + estimateDTO.entries, + ); + // Edits estimate transaction with associated transactions + // under unit-of-work envirement. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Trigger `onSaleEstimateEditing` event. + await this.eventPublisher.emitAsync(events.saleEstimate.onEditing, { + oldSaleEstimate, + estimateDTO, + trx, + } as ISaleEstimateEditingPayload); + + // Upsert the estimate graph to the storage. + const saleEstimate = await this.saleEstimateModel + .query(trx) + .upsertGraphAndFetch({ + id: estimateId, + ...estimateObj, + }); + + // Trigger `onSaleEstimateEdited` event. + await this.eventPublisher.emitAsync(events.saleEstimate.onEdited, { + estimateId, + saleEstimate, + oldSaleEstimate, + estimateDTO, + trx, + } as ISaleEstimateEditedPayload); + + return saleEstimate; + }); + } +} diff --git a/packages/server-nest/src/modules/SaleEstimates/commands/RejectSaleEstimate.service.ts b/packages/server-nest/src/modules/SaleEstimates/commands/RejectSaleEstimate.service.ts new file mode 100644 index 000000000..b5f07c9f5 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/commands/RejectSaleEstimate.service.ts @@ -0,0 +1,50 @@ +import { Inject, Injectable } from '@nestjs/common'; +import moment from 'moment'; +import { Knex } from 'knex'; +import { SaleEstimate } from '../models/SaleEstimate'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { ERRORS } from '../constants'; +import { events } from '@/common/events/events'; + + +@Injectable() +export class RejectSaleEstimateService { + constructor( + @Inject(SaleEstimate.name) + private readonly saleEstimateModel: typeof SaleEstimate, + private readonly eventPublisher: EventEmitter2, + private readonly uow: UnitOfWork, + ) {} + + /** + * Mark the sale estimate as rejected from the customer. + * @param {number} saleEstimateId + */ + public async rejectSaleEstimate(saleEstimateId: number): Promise { + // Retrieve details of the given sale estimate id. + const saleEstimate = await this.saleEstimateModel.query() + .findById(saleEstimateId) + .throwIfNotFound(); + + // Throws error in case the sale estimate still not delivered to customer. + if (!saleEstimate.isDelivered) { + throw new ServiceError(ERRORS.SALE_ESTIMATE_NOT_DELIVERED); + } + // Throws error in case the sale estimate already rejected. + if (saleEstimate.isRejected) { + throw new ServiceError(ERRORS.SALE_ESTIMATE_ALREADY_REJECTED); + } + + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Mark the sale estimate as reject on the storage. + await this.saleEstimateModel.query(trx).where('id', saleEstimateId).patch({ + rejectedAt: moment().toMySqlDateTime(), + approvedAt: null, + }); + // Triggers `onSaleEstimateRejected` event. + await this.eventPublisher.emitAsync(events.saleEstimate.onRejected, {}); + }); + } +} diff --git a/packages/server-nest/src/modules/SaleEstimates/commands/SaleEstimateDTOTransformer.service.ts b/packages/server-nest/src/modules/SaleEstimates/commands/SaleEstimateDTOTransformer.service.ts new file mode 100644 index 000000000..7da69c35d --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/commands/SaleEstimateDTOTransformer.service.ts @@ -0,0 +1,115 @@ +import * as R from 'ramda'; +import { Inject, Injectable } from '@nestjs/common'; +import { omit, sumBy } from 'lodash'; +import * as composeAsync from 'async/compose'; +// import { ICustomer, ISaleEstimate, ISaleEstimateDTO } from '../types/SaleEstimates.types'; +import { SaleEstimateValidators } from './SaleEstimateValidators.service'; +// import { BranchTransactionDTOTransform } from '@/services/Branches/Integrations/BranchTransactionDTOTransform'; +// import { WarehouseTransactionDTOTransform } from '@/services/Warehouses/Integrations/WarehouseTransactionDTOTransform'; +import { formatDateFields } from '@/utils/format-date-fields'; +import * as moment from 'moment'; +import { SaleEstimateIncrement } from './SaleEstimateIncrement.service'; +import { BranchTransactionDTOTransformer } from '@/modules/Branches/integrations/BranchTransactionDTOTransform'; +import { WarehouseTransactionDTOTransform } from '@/modules/Warehouses/Integrations/WarehouseTransactionDTOTransform'; +import { assocItemEntriesDefaultIndex } from '@/utils/associate-item-entries-index'; +import { BrandingTemplateDTOTransformer } from '@/modules/PdfTemplate/BrandingTemplateDTOTransformer'; +import { ItemEntry } from '@/modules/TransactionItemEntry/models/ItemEntry'; +import { SaleEstimate } from '../models/SaleEstimate'; +import { Customer } from '@/modules/Customers/models/Customer'; +import { ISaleEstimateDTO } from '../types/SaleEstimates.types'; +// import { assocItemEntriesDefaultIndex } from '@/services/Items/utils'; +// import { BrandingTemplateDTOTransformer } from '@/services/PdfTemplate/BrandingTemplateDTOTransformer'; + +@Injectable() +export class SaleEstimateDTOTransformer { + constructor( + @Inject(ItemEntry.name) + private itemEntryModel: typeof ItemEntry, + private readonly validators: SaleEstimateValidators, + private readonly branchDTOTransform: BranchTransactionDTOTransformer, + private readonly warehouseDTOTransform: WarehouseTransactionDTOTransform, + private readonly estimateIncrement: SaleEstimateIncrement, + private readonly brandingTemplatesTransformer: BrandingTemplateDTOTransformer, + ) {} + + /** + * Transform create DTO object ot model object. + * @param {ISaleEstimateDTO} saleEstimateDTO - Sale estimate DTO. + * @param {Customer} paymentCustomer - Payment customer. + * @param {SaleEstimate} oldSaleEstimate - Old sale estimate. + * @return {ISaleEstimate} + */ + async transformDTOToModel( + estimateDTO: ISaleEstimateDTO, + paymentCustomer: Customer, + oldSaleEstimate?: SaleEstimate + ): Promise { + const amount = sumBy(estimateDTO.entries, (e) => + this.itemEntryModel.calcAmount(e) + ); + // Retrieve the next invoice number. + const autoNextNumber = this.estimateIncrement.getNextEstimateNumber(); + + // Retrieve the next estimate number. + const estimateNumber = + estimateDTO.estimateNumber || + oldSaleEstimate?.estimateNumber || + autoNextNumber; + + // Validate the sale estimate number require. + this.validators.validateEstimateNoRequire(estimateNumber); + + const entries = R.compose( + // Associate the reference type to item entries. + R.map((entry) => R.assoc('reference_type', 'SaleEstimate', entry)), + + // Associate default index to item entries. + assocItemEntriesDefaultIndex + )(estimateDTO.entries); + + const initialDTO = { + amount, + ...formatDateFields( + omit(estimateDTO, ['delivered', 'entries', 'attachments']), + ['estimateDate', 'expirationDate'] + ), + currencyCode: paymentCustomer.currencyCode, + exchangeRate: estimateDTO.exchangeRate || 1, + ...(estimateNumber ? { estimateNumber } : {}), + entries, + // Avoid rewrite the deliver date in edit mode when already published. + ...(estimateDTO.delivered && + !oldSaleEstimate?.deliveredAt && { + deliveredAt: moment().toMySqlDateTime(), + }), + }; + const initialAsyncDTO = await composeAsync( + // Assigns the default branding template id to the invoice DTO. + this.brandingTemplatesTransformer.assocDefaultBrandingTemplate( + 'SaleEstimate' + ) + )(initialDTO); + + return R.compose( + this.branchDTOTransform.transformDTO, + this.warehouseDTOTransform.transformDTO, + )(initialAsyncDTO); + } + + /** + * Retrieve estimate number to object model. + * @param {ISaleEstimateDTO} saleEstimateDTO + * @param {ISaleEstimate} oldSaleEstimate + */ + public transformEstimateNumberToModel( + saleEstimateDTO: ISaleEstimateDTO, + oldSaleEstimate?: SaleEstimate + ): string { + const autoNextNumber = this.estimateIncrement.getNextEstimateNumber(); + + if (saleEstimateDTO.estimateNumber) { + return saleEstimateDTO.estimateNumber; + } + return oldSaleEstimate ? oldSaleEstimate.estimateNumber : autoNextNumber; + } +} diff --git a/packages/server-nest/src/modules/SaleEstimates/commands/SaleEstimateIncrement.service.ts b/packages/server-nest/src/modules/SaleEstimates/commands/SaleEstimateIncrement.service.ts new file mode 100644 index 000000000..aea9f30a2 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/commands/SaleEstimateIncrement.service.ts @@ -0,0 +1,28 @@ +import { AutoIncrementOrdersService } from '@/modules/AutoIncrementOrders/AutoIncrementOrders.service'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class SaleEstimateIncrement { + constructor( + private readonly autoIncrementOrdersService: AutoIncrementOrdersService, + ) {} + + /** + * Retrieve the next unique estimate number. + * @return {string} + */ + public getNextEstimateNumber(): string { + return this.autoIncrementOrdersService.getNextTransactionNumber( + 'sales_estimates', + ); + } + + /** + * Increment the estimate next number. + */ + public incrementNextEstimateNumber() { + return this.autoIncrementOrdersService.incrementSettingsNextNumber( + 'sales_estimates', + ); + } +} diff --git a/packages/server-nest/src/modules/SaleEstimates/commands/SaleEstimateSmsNotify.ts b/packages/server-nest/src/modules/SaleEstimates/commands/SaleEstimateSmsNotify.ts new file mode 100644 index 000000000..2169b7272 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/commands/SaleEstimateSmsNotify.ts @@ -0,0 +1,217 @@ +// import { Service, Inject } from 'typedi'; +// import moment from 'moment'; +// import events from '@/subscribers/events'; +// import HasTenancyService from '@/services/Tenancy/TenancyService'; +// import SaleNotifyBySms from '../SaleNotifyBySms'; +// import SmsNotificationsSettingsService from '@/services/Settings/SmsNotificationsSettings'; +// import { +// ICustomer, +// IPaymentReceivedSmsDetails, +// ISaleEstimate, +// SMS_NOTIFICATION_KEY, +// } from '@/interfaces'; +// import { Tenant, TenantMetadata } from '@/system/models'; +// import { formatNumber, formatSmsMessage } from 'utils'; +// import { ServiceError } from '@/exceptions'; +// import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; + +// const ERRORS = { +// SALE_ESTIMATE_NOT_FOUND: 'SALE_ESTIMATE_NOT_FOUND', +// }; + +// @Service() +// export class SaleEstimateNotifyBySms { +// @Inject() +// private tenancy: HasTenancyService; + +// @Inject() +// private saleSmsNotification: SaleNotifyBySms; + +// @Inject() +// private eventPublisher: EventPublisher; + +// @Inject() +// private smsNotificationsSettings: SmsNotificationsSettingsService; + +// /** +// * +// * @param {number} tenantId +// * @param {number} saleEstimateId +// * @returns {Promise} +// */ +// public notifyBySms = async ( +// tenantId: number, +// saleEstimateId: number +// ): Promise => { +// const { SaleEstimate } = this.tenancy.models(tenantId); + +// // Retrieve the sale invoice or throw not found service error. +// const saleEstimate = await SaleEstimate.query() +// .findById(saleEstimateId) +// .withGraphFetched('customer'); + +// // Validates the estimate transaction existance. +// this.validateEstimateExistance(saleEstimate); + +// // Validate the customer phone number existance and number validation. +// this.saleSmsNotification.validateCustomerPhoneNumber( +// saleEstimate.customer.personalPhone +// ); +// // Triggers `onSaleEstimateNotifySms` event. +// await this.eventPublisher.emitAsync(events.saleEstimate.onNotifySms, { +// tenantId, +// saleEstimate, +// }); +// await this.sendSmsNotification(tenantId, saleEstimate); + +// // Triggers `onSaleEstimateNotifySms` event. +// await this.eventPublisher.emitAsync(events.saleEstimate.onNotifiedSms, { +// tenantId, +// saleEstimate, +// }); +// return saleEstimate; +// }; + +// /** +// * +// * @param {number} tenantId +// * @param {ISaleEstimate} saleEstimate +// * @returns +// */ +// private sendSmsNotification = async ( +// tenantId: number, +// saleEstimate: ISaleEstimate & { customer: ICustomer } +// ) => { +// const smsClient = this.tenancy.smsClient(tenantId); +// const tenantMetadata = await TenantMetadata.query().findOne({ tenantId }); + +// // Retrieve the formatted sms notification message for estimate details. +// const formattedSmsMessage = this.formattedEstimateDetailsMessage( +// tenantId, +// saleEstimate, +// tenantMetadata +// ); +// const phoneNumber = saleEstimate.customer.personalPhone; + +// // Runs the send message job. +// return smsClient.sendMessageJob(phoneNumber, formattedSmsMessage); +// }; + +// /** +// * Notify via SMS message after estimate creation. +// * @param {number} tenantId +// * @param {number} saleEstimateId +// * @returns {Promise} +// */ +// public notifyViaSmsNotificationAfterCreation = async ( +// tenantId: number, +// saleEstimateId: number +// ): Promise => { +// const notification = this.smsNotificationsSettings.getSmsNotificationMeta( +// tenantId, +// SMS_NOTIFICATION_KEY.SALE_ESTIMATE_DETAILS +// ); +// // Can't continue if the sms auto-notification is not enabled. +// if (!notification.isNotificationEnabled) return; + +// await this.notifyBySms(tenantId, saleEstimateId); +// }; + +// /** +// * +// * @param {number} tenantId +// * @param {ISaleEstimate} saleEstimate +// * @param {TenantMetadata} tenantMetadata +// * @returns {string} +// */ +// private formattedEstimateDetailsMessage = ( +// tenantId: number, +// saleEstimate: ISaleEstimate, +// tenantMetadata: TenantMetadata +// ): string => { +// const notification = this.smsNotificationsSettings.getSmsNotificationMeta( +// tenantId, +// SMS_NOTIFICATION_KEY.SALE_ESTIMATE_DETAILS +// ); +// return this.formateEstimateDetailsMessage( +// notification.smsMessage, +// saleEstimate, +// tenantMetadata +// ); +// }; + +// /** +// * Formattes the estimate sms notification details message. +// * @param {string} smsMessage +// * @param {ISaleEstimate} saleEstimate +// * @param {TenantMetadata} tenantMetadata +// * @returns {string} +// */ +// private formateEstimateDetailsMessage = ( +// smsMessage: string, +// saleEstimate: ISaleEstimate & { customer: ICustomer }, +// tenantMetadata: TenantMetadata +// ) => { +// const formattedAmount = formatNumber(saleEstimate.amount, { +// currencyCode: saleEstimate.currencyCode, +// }); + +// return formatSmsMessage(smsMessage, { +// EstimateNumber: saleEstimate.estimateNumber, +// ReferenceNumber: saleEstimate.reference, +// EstimateDate: moment(saleEstimate.estimateDate).format('YYYY/MM/DD'), +// ExpirationDate: saleEstimate.expirationDate +// ? moment(saleEstimate.expirationDate).format('YYYY/MM/DD') +// : '', +// CustomerName: saleEstimate.customer.displayName, +// Amount: formattedAmount, +// CompanyName: tenantMetadata.name, +// }); +// }; + +// /** +// * Retrieve the SMS details of the given payment receive transaction. +// * @param {number} tenantId +// * @param {number} saleEstimateId +// * @returns {Promise} +// */ +// public smsDetails = async ( +// tenantId: number, +// saleEstimateId: number +// ): Promise => { +// const { SaleEstimate } = this.tenancy.models(tenantId); + +// // Retrieve the sale invoice or throw not found service error. +// const saleEstimate = await SaleEstimate.query() +// .findById(saleEstimateId) +// .withGraphFetched('customer'); + +// // Validates the estimate existance. +// this.validateEstimateExistance(saleEstimate); + +// // Retrieve the current tenant metadata. +// const tenantMetadata = await TenantMetadata.query().findOne({ tenantId }); + +// // Retrieve the formatted sms message from the given estimate model. +// const formattedSmsMessage = this.formattedEstimateDetailsMessage( +// tenantId, +// saleEstimate, +// tenantMetadata +// ); +// return { +// customerName: saleEstimate.customer.displayName, +// customerPhoneNumber: saleEstimate.customer.personalPhone, +// smsMessage: formattedSmsMessage, +// }; +// }; + +// /** +// * Validates the sale estimate existance. +// * @param {ISaleEstimate} saleEstimate - +// */ +// private validateEstimateExistance(saleEstimate: ISaleEstimate) { +// if (!saleEstimate) { +// throw new ServiceError(ERRORS.SALE_ESTIMATE_NOT_FOUND); +// } +// } +// } diff --git a/packages/server-nest/src/modules/SaleEstimates/commands/SaleEstimateValidators.service.ts b/packages/server-nest/src/modules/SaleEstimates/commands/SaleEstimateValidators.service.ts new file mode 100644 index 000000000..7746c5626 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/commands/SaleEstimateValidators.service.ts @@ -0,0 +1,81 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { ERRORS } from '../constants'; +import { SaleEstimate } from '../models/SaleEstimate'; +import { ServiceError } from '@/modules/Items/ServiceError'; + +@Injectable() +export class SaleEstimateValidators { + constructor( + @Inject(SaleEstimate.name) + private readonly saleEstimateModel: typeof SaleEstimate, + ) {} + + /** + * Validates the given estimate existance. + * @param {SaleEstimate | undefined | null} estimate - The sale estimate. + */ + public validateEstimateExistance(estimate: SaleEstimate | undefined | null) { + if (!estimate) { + throw new ServiceError(ERRORS.SALE_ESTIMATE_NOT_FOUND); + } + } + + /** + * Validate the estimate number unique on the storage. + * @param {string} estimateNumber - The estimate number. + * @param {number} notEstimateId - The estimate id to exclude from the search. + */ + public async validateEstimateNumberExistance( + estimateNumber: string, + notEstimateId?: number, + ) { + const foundSaleEstimate = await this.saleEstimateModel + .query() + .findOne('estimate_number', estimateNumber) + .onBuild((builder) => { + if (notEstimateId) { + builder.whereNot('id', notEstimateId); + } + }); + if (foundSaleEstimate) { + throw new ServiceError( + ERRORS.SALE_ESTIMATE_NUMBER_EXISTANCE, + 'The given sale estimate is not unique.', + ); + } + } + + /** + * Validates the given sale estimate not already converted to invoice. + * @param {SaleEstimate} saleEstimate - + */ + public validateEstimateNotConverted(saleEstimate: SaleEstimate) { + if (saleEstimate.isConvertedToInvoice) { + throw new ServiceError(ERRORS.SALE_ESTIMATE_CONVERTED_TO_INVOICE); + } + } + + /** + * Validate the sale estimate number require. + * @param {string} estimateNumber + */ + public validateEstimateNoRequire(estimateNumber: string) { + if (!estimateNumber) { + throw new ServiceError(ERRORS.SALE_ESTIMATE_NO_IS_REQUIRED); + } + } + + /** + * Validate the given customer has no sales estimates. + * @param {number} customerId - The customer id. + */ + public async validateCustomerHasNoEstimates(customerId: number) { + const estimates = await this.saleEstimateModel + .query() + .where('customer_id', customerId); + + if (estimates.length > 0) { + throw new ServiceError(ERRORS.CUSTOMER_HAS_SALES_ESTIMATES); + } + } +} diff --git a/packages/server-nest/src/modules/SaleEstimates/commands/SendSaleEstimateMail.ts b/packages/server-nest/src/modules/SaleEstimates/commands/SendSaleEstimateMail.ts new file mode 100644 index 000000000..470a4d879 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/commands/SendSaleEstimateMail.ts @@ -0,0 +1,205 @@ +// import { Inject, Service } from 'typedi'; +// import Mail from '@/lib/Mail'; +// import HasTenancyService from '@/services/Tenancy/TenancyService'; +// import { +// DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT, +// DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT, +// } from '../constants'; +// import { SaleEstimatesPdf } from '../queries/SaleEstimatesPdf'; +// import { GetSaleEstimate } from '../queries/GetSaleEstimate.service'; +// import { +// ISaleEstimateMailPresendEvent, +// SaleEstimateMailOptions, +// SaleEstimateMailOptionsDTO, +// } from '@/interfaces'; +// import { ContactMailNotification } from '@/services/MailNotification/ContactMailNotification'; +// import { mergeAndValidateMailOptions } from '@/services/MailNotification/utils'; +// import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +// import events from '@/subscribers/events'; +// import { transformEstimateToMailDataArgs } from '../utils'; + +// @Service() +// export class SendSaleEstimateMail { +// @Inject() +// private tenancy: HasTenancyService; + +// @Inject() +// private estimatePdf: SaleEstimatesPdf; + +// @Inject() +// private getSaleEstimateService: GetSaleEstimate; + +// @Inject() +// private contactMailNotification: ContactMailNotification; + +// @Inject('agenda') +// private agenda: any; + +// @Inject() +// private eventPublisher: EventPublisher; + +// /** +// * Triggers the reminder mail of the given sale estimate. +// * @param {number} tenantId - +// * @param {number} saleEstimateId - +// * @param {SaleEstimateMailOptionsDTO} messageOptions - +// * @returns {Promise} +// */ +// public async triggerMail( +// tenantId: number, +// saleEstimateId: number, +// messageOptions: SaleEstimateMailOptionsDTO +// ): Promise { +// const payload = { +// tenantId, +// saleEstimateId, +// messageOptions, +// }; +// await this.agenda.now('sale-estimate-mail-send', payload); + +// // Triggers `onSaleEstimatePreMailSend` event. +// await this.eventPublisher.emitAsync(events.saleEstimate.onPreMailSend, { +// tenantId, +// saleEstimateId, +// messageOptions, +// } as ISaleEstimateMailPresendEvent); +// } + +// /** +// * Formate the text of the mail. +// * @param {number} tenantId - Tenant id. +// * @param {number} estimateId - Estimate id. +// * @returns {Promise>} +// */ +// public formatterArgs = async (tenantId: number, estimateId: number) => { +// const estimate = await this.getSaleEstimateService.getEstimate( +// tenantId, +// estimateId +// ); +// return transformEstimateToMailDataArgs(estimate); +// }; + +// /** +// * Retrieves the mail options. +// * @param {number} tenantId +// * @param {number} saleEstimateId +// * @returns {Promise} +// */ +// public getMailOptions = async ( +// tenantId: number, +// saleEstimateId: number, +// defaultSubject: string = DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT, +// defaultMessage: string = DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT +// ): Promise => { +// const { SaleEstimate } = this.tenancy.models(tenantId); + +// const saleEstimate = await SaleEstimate.query() +// .findById(saleEstimateId) +// .throwIfNotFound(); + +// const formatArgs = await this.formatterArgs(tenantId, saleEstimateId); + +// const mailOptions = +// await this.contactMailNotification.getDefaultMailOptions( +// tenantId, +// saleEstimate.customerId +// ); +// return { +// ...mailOptions, +// message: defaultMessage, +// subject: defaultSubject, +// attachEstimate: true, +// formatArgs, +// }; +// }; + +// /** +// * Formats the given mail options. +// * @param {number} tenantId +// * @param {number} saleEstimateId +// * @param {SaleEstimateMailOptions} mailOptions +// * @returns {Promise} +// */ +// public formatMailOptions = async ( +// tenantId: number, +// saleEstimateId: number, +// mailOptions: SaleEstimateMailOptions +// ): Promise => { +// const formatterArgs = await this.formatterArgs(tenantId, saleEstimateId); +// const formattedOptions = +// await this.contactMailNotification.formatMailOptions( +// tenantId, +// mailOptions, +// formatterArgs +// ); +// return { ...formattedOptions }; +// }; + +// /** +// * Sends the mail notification of the given sale estimate. +// * @param {number} tenantId +// * @param {number} saleEstimateId +// * @param {SaleEstimateMailOptions} messageOptions +// * @returns {Promise} +// */ +// public async sendMail( +// tenantId: number, +// saleEstimateId: number, +// messageOptions: SaleEstimateMailOptionsDTO +// ): Promise { +// const localMessageOpts = await this.getMailOptions( +// tenantId, +// saleEstimateId +// ); +// // Overrides and validates the given mail options. +// const parsedMessageOptions = mergeAndValidateMailOptions( +// localMessageOpts, +// messageOptions +// ) as SaleEstimateMailOptions; + +// const formattedOptions = await this.formatMailOptions( +// tenantId, +// saleEstimateId, +// parsedMessageOptions +// ); +// const mail = new Mail() +// .setSubject(formattedOptions.subject) +// .setTo(formattedOptions.to) +// .setCC(formattedOptions.cc) +// .setBCC(formattedOptions.bcc) +// .setContent(formattedOptions.message); + +// // Attaches the estimate pdf to the mail. +// if (formattedOptions.attachEstimate) { +// // Retrieves the estimate pdf and attaches it to the mail. +// const [estimatePdfBuffer, estimateFilename] = +// await this.estimatePdf.getSaleEstimatePdf(tenantId, saleEstimateId); + +// mail.setAttachments([ +// { +// filename: `${estimateFilename}.pdf`, +// content: estimatePdfBuffer, +// }, +// ]); +// } + +// const eventPayload = { +// tenantId, +// saleEstimateId, +// messageOptions, +// formattedOptions, +// }; +// // Triggers `onSaleEstimateMailSend` event. +// await this.eventPublisher.emitAsync( +// events.saleEstimate.onMailSend, +// eventPayload as ISaleEstimateMailPresendEvent +// ); +// await mail.send(); + +// // Triggers `onSaleEstimateMailSent` event. +// await this.eventPublisher.emitAsync( +// events.saleEstimate.onMailSent, +// eventPayload as ISaleEstimateMailPresendEvent +// ); +// } +// } diff --git a/packages/server-nest/src/modules/SaleEstimates/commands/SendSaleEstimateMailJob.ts b/packages/server-nest/src/modules/SaleEstimates/commands/SendSaleEstimateMailJob.ts new file mode 100644 index 000000000..184df557a --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/commands/SendSaleEstimateMailJob.ts @@ -0,0 +1,36 @@ +// import Container, { Service } from 'typedi'; +// import { SendSaleEstimateMail } from './SendSaleEstimateMail'; + +// @Service() +// export class SendSaleEstimateMailJob { +// /** +// * Constructor method. +// */ +// constructor(agenda) { +// agenda.define( +// 'sale-estimate-mail-send', +// { priority: 'high', concurrency: 2 }, +// this.handler +// ); +// } + +// /** +// * Triggers sending invoice mail. +// */ +// private handler = async (job, done: Function) => { +// const { tenantId, saleEstimateId, messageOptions } = job.attrs.data; +// const sendSaleEstimateMail = Container.get(SendSaleEstimateMail); + +// try { +// await sendSaleEstimateMail.sendMail( +// tenantId, +// saleEstimateId, +// messageOptions +// ); +// done(); +// } catch (error) { +// console.log(error); +// done(error); +// } +// }; +// } diff --git a/packages/server-nest/src/modules/SaleEstimates/commands/UnlinkConvertedSaleEstimate.service.ts b/packages/server-nest/src/modules/SaleEstimates/commands/UnlinkConvertedSaleEstimate.service.ts new file mode 100644 index 000000000..4bd05de52 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/commands/UnlinkConvertedSaleEstimate.service.ts @@ -0,0 +1,30 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Knex } from 'knex'; +import { SaleEstimate } from '../models/SaleEstimate'; + +@Injectable() +export class UnlinkConvertedSaleEstimate { + constructor( + @Inject(SaleEstimate.name) + private readonly saleEstimateModel: typeof SaleEstimate, + ) {} + + /** + * Unlink the converted sale estimates from the given sale invoice. + * @param {number} invoiceId - + * @return {Promise} + */ + public async unlinkConvertedEstimateFromInvoice( + invoiceId: number, + trx?: Knex.Transaction + ): Promise { + await this.saleEstimateModel.query(trx) + .where({ + convertedToInvoiceId: invoiceId, + }) + .patch({ + convertedToInvoiceId: null, + convertedToInvoiceAt: null, + }); + } +} diff --git a/packages/server-nest/src/modules/SaleEstimates/constants.ts b/packages/server-nest/src/modules/SaleEstimates/constants.ts new file mode 100644 index 000000000..798e8f090 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/constants.ts @@ -0,0 +1,287 @@ +export const DEFAULT_ESTIMATE_REMINDER_MAIL_SUBJECT = + 'Estimate {Estimate Number} is awaiting your approval'; +export const DEFAULT_ESTIMATE_REMINDER_MAIL_CONTENT = `

Dear {Customer Name}

+

Thank you for your business, You can view or print your estimate from attachements.

+

+Estimate #{Estimate Number}
+Expiration Date : {Estimate Expiration Date}
+Amount : {Estimate Amount}
+

+ +

+Regards
+{Company Name} +

+`; + +export const ERRORS = { + SALE_ESTIMATE_NOT_FOUND: 'SALE_ESTIMATE_NOT_FOUND', + SALE_ESTIMATE_NUMBER_EXISTANCE: 'SALE_ESTIMATE_NUMBER_EXISTANCE', + SALE_ESTIMATE_CONVERTED_TO_INVOICE: 'SALE_ESTIMATE_CONVERTED_TO_INVOICE', + SALE_ESTIMATE_NOT_DELIVERED: 'SALE_ESTIMATE_NOT_DELIVERED', + SALE_ESTIMATE_ALREADY_REJECTED: 'SALE_ESTIMATE_ALREADY_REJECTED', + CUSTOMER_HAS_SALES_ESTIMATES: 'CUSTOMER_HAS_SALES_ESTIMATES', + SALE_ESTIMATE_NO_IS_REQUIRED: 'SALE_ESTIMATE_NO_IS_REQUIRED', + SALE_ESTIMATE_ALREADY_DELIVERED: 'SALE_ESTIMATE_ALREADY_DELIVERED', + SALE_ESTIMATE_ALREADY_APPROVED: 'SALE_ESTIMATE_ALREADY_APPROVED', +}; + +export const DEFAULT_VIEW_COLUMNS = []; +export const DEFAULT_VIEWS = [ + { + name: 'Draft', + slug: 'draft', + rolesLogicExpression: '1', + roles: [ + { index: 1, fieldKey: 'status', comparator: 'equals', value: 'draft' }, + ], + columns: DEFAULT_VIEW_COLUMNS, + }, + { + name: 'Delivered', + slug: 'delivered', + rolesLogicExpression: '1', + roles: [ + { + index: 1, + fieldKey: 'status', + comparator: 'equals', + value: 'delivered', + }, + ], + columns: DEFAULT_VIEW_COLUMNS, + }, + { + name: 'Approved', + slug: 'approved', + rolesLogicExpression: '1', + roles: [ + { + index: 1, + fieldKey: 'status', + comparator: 'equals', + value: 'approved', + }, + ], + columns: DEFAULT_VIEW_COLUMNS, + }, + { + name: 'Rejected', + slug: 'rejected', + rolesLogicExpression: '1', + roles: [ + { + index: 1, + fieldKey: 'status', + comparator: 'equals', + value: 'rejected', + }, + ], + columns: DEFAULT_VIEW_COLUMNS, + }, + { + name: 'Invoiced', + slug: 'invoiced', + rolesLogicExpression: '1', + roles: [ + { + index: 1, + fieldKey: 'status', + comparator: 'equals', + value: 'invoiced', + }, + ], + columns: DEFAULT_VIEW_COLUMNS, + }, + { + name: 'Expired', + slug: 'expired', + rolesLogicExpression: '1', + roles: [ + { + index: 1, + fieldKey: 'status', + comparator: 'equals', + value: 'expired', + }, + ], + columns: DEFAULT_VIEW_COLUMNS, + }, + { + name: 'Closed', + slug: 'closed', + rolesLogicExpression: '1', + roles: [ + { + index: 1, + fieldKey: 'status', + comparator: 'equals', + value: 'closed', + }, + ], + columns: DEFAULT_VIEW_COLUMNS, + }, +]; + +export const SaleEstimatesSampleData = [ + { + Customer: 'Ambrose Olson', + 'Estimate Date': '2024-01-01', + 'Expiration Date': '2025-01-01', + 'Estimate No.': 'EST-0001', + 'Reference No.': 'REF-0001', + Currency: '', + 'Exchange Rate': '', + Note: 'Vel autem quis aut ab.', + 'Terms & Conditions': 'Provident illo architecto sit iste in.', + Delivered: 'T', + 'Item Name': 'Hettinger, Schumm and Bartoletti', + Quantity: 1000, + Rate: 20, + 'Line Description': 'Rem esse doloremque praesentium harum maiores.', + }, + { + Customer: 'Ambrose Olson', + 'Estimate Date': '2024-01-02', + 'Expiration Date': '2025-01-02', + 'Estimate No.': 'EST-0002', + 'Reference No.': 'REF-0002', + Currency: '', + 'Exchange Rate': '', + Note: 'Tempora voluptas odio deleniti rerum vitae consequatur nihil quis sunt.', + 'Terms & Conditions': 'Ut eum incidunt quibusdam rerum vero.', + Delivered: 'T', + 'Item Name': 'Hettinger, Schumm and Bartoletti', + Quantity: 1000, + Rate: 20, + 'Line Description': 'Qui voluptate aliquam maxime aliquam.', + }, + { + Customer: 'Ambrose Olson', + 'Estimate Date': '2024-01-03', + 'Expiration Date': '2025-01-03', + 'Estimate No.': 'EST-0003', + 'Reference No.': 'REF-0003', + Currency: '', + 'Exchange Rate': '', + Note: 'Quia voluptatem delectus doloremque.', + 'Terms & Conditions': 'Facilis porro vitae ratione.', + Delivered: 'T', + 'Item Name': 'Hettinger, Schumm and Bartoletti', + Quantity: 1000, + Rate: 20, + 'Line Description': 'Qui suscipit ducimus qui qui.', + }, +]; + +export const defaultEstimatePdfBrandingAttributes = { + primaryColor: '#000', + secondaryColor: '#000', + + // # Company logo + showCompanyLogo: true, + companyLogoUri: '', + companyLogoKey: '', + + companyName: '', + + customerAddress: '', + companyAddress: '', + showCustomerAddress: true, + showCompanyAddress: true, + billedToLabel: 'Billed To', + + total: '$1000.00', + totalLabel: 'Total', + showTotal: true, + + subtotal: '1000/00', + subtotalLabel: 'Subtotal', + showSubtotal: true, + + showCustomerNote: true, + customerNote: + 'It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.', + customerNoteLabel: 'Customer Note', + + showTermsConditions: true, + termsConditions: + 'It is a long established fact that a reader will be distracted by the readable content of a page when looking at its layout.', + termsConditionsLabel: 'Terms & Conditions', + + lines: [ + { + item: 'Simply dummy text', + description: 'Simply dummy text of the printing and typesetting', + rate: '1', + quantity: '1000', + total: '$1000.00', + }, + ], + showEstimateNumber: true, + estimateNumberLabel: 'Estimate Number', + estimateNumebr: '346D3D40-0001', + + estimateDate: 'September 3, 2024', + showEstimateDate: true, + estimateDateLabel: 'Estimate Date', + + expirationDateLabel: 'Expiration Date', + showExpirationDate: true, + expirationDate: 'September 3, 2024', +}; + +interface EstimatePdfBrandingLineItem { + item: string; + description: string; + rate: string; + quantity: string; + total: string; +} + +export interface EstimatePdfBrandingAttributes { + primaryColor: string; + secondaryColor: string; + showCompanyLogo: boolean; + companyLogo: string; + companyName: string; + + // Customer Address + showCustomerAddress: boolean; + customerAddress: string; + + // Company Address + showCompanyAddress: boolean; + companyAddress: string; + billedToLabel: string; + + total: string; + totalLabel: string; + showTotal: boolean; + + subtotal: string; + subtotalLabel: string; + showSubtotal: boolean; + + showCustomerNote: boolean; + customerNote: string; + customerNoteLabel: string; + + showTermsConditions: boolean; + termsConditions: string; + termsConditionsLabel: string; + + lines: EstimatePdfBrandingLineItem[]; + + showEstimateNumber: boolean; + estimateNumberLabel: string; + estimateNumebr: string; + + estimateDate: string; + showEstimateDate: boolean; + estimateDateLabel: string; + + expirationDateLabel: string; + showExpirationDate: boolean; + expirationDate: string; +} diff --git a/packages/server-nest/src/modules/SaleEstimates/models/SaleEstimate.ts b/packages/server-nest/src/modules/SaleEstimates/models/SaleEstimate.ts new file mode 100644 index 000000000..2789b4722 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/models/SaleEstimate.ts @@ -0,0 +1,318 @@ +import { BaseModel } from '@/models/Model'; +import moment from 'moment'; +import { Model } from 'objection'; +// import TenantModel from 'models/TenantModel'; +// import { defaultToTransform } from 'utils'; +// import SaleEstimateSettings from './SaleEstimate.Settings'; +// import ModelSetting from './ModelSetting'; +// import CustomViewBaseModel from './CustomViewBaseModel'; +// import { DEFAULT_VIEWS } from '@/services/Sales/Estimates/constants'; +// import ModelSearchable from './ModelSearchable'; + +export class SaleEstimate extends BaseModel { + exchangeRate!: number; + amount!: number; + + currencyCode!: string; + + customerId!: number; + estimateDate!: Date | string; + expirationDate!: Date | string; + reference!: string; + estimateNumber!: string; + note!: string; + termsConditions!: string; + sendToEmail!: string; + + deliveredAt!: Date | string; + approvedAt!: Date | string; + rejectedAt!: Date | string; + + userId!: number; + + convertedToInvoiceId!: number; + convertedToInvoiceAt!: Date | string; + + createdAt?: Date; + updatedAt?: Date | null; + + branchId?: number; + warehouseId?: number; + + /** + * Table name + */ + static get tableName() { + return 'sales_estimates'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return [ + 'localAmount', + 'isDelivered', + 'isExpired', + 'isConvertedToInvoice', + 'isApproved', + 'isRejected', + ]; + } + + /** + * Estimate amount in local currency. + * @returns {number} + */ + get localAmount() { + return this.amount * this.exchangeRate; + } + + /** + * Detarmines whether the sale estimate converted to sale invoice. + * @return {boolean} + */ + get isConvertedToInvoice() { + return !!(this.convertedToInvoiceId && this.convertedToInvoiceAt); + } + + /** + * Detarmines whether the estimate is delivered. + * @return {boolean} + */ + get isDelivered() { + return !!this.deliveredAt; + } + + /** + * Detarmines whether the estimate is expired. + * @return {boolean} + */ + get isExpired() { + // return defaultToTransform( + // this.expirationDate, + // moment().isAfter(this.expirationDate, 'day'), + // false + // );i + + return false; + } + + /** + * Detarmines whether the estimate is approved. + * @return {boolean} + */ + get isApproved() { + return !!this.approvedAt; + } + + /** + * Detarmines whether the estimate is reject. + * @return {boolean} + */ + get isRejected() { + return !!this.rejectedAt; + } + + /** + * Allows to mark model as resourceable to viewable and filterable. + */ + static get resourceable() { + return true; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + /** + * Filters the drafted estimates transactions. + */ + draft(query) { + query.where('delivered_at', null); + }, + /** + * Filters the delivered estimates transactions. + */ + delivered(query) { + query.whereNot('delivered_at', null); + }, + /** + * Filters the expired estimates transactions. + */ + expired(query) { + query.where('expiration_date', '<', moment().format('YYYY-MM-DD')); + }, + /** + * Filters the rejected estimates transactions. + */ + rejected(query) { + query.whereNot('rejected_at', null); + }, + /** + * Filters the invoiced estimates transactions. + */ + invoiced(query) { + query.whereNot('converted_to_invoice_at', null); + }, + /** + * Filters the approved estimates transactions. + */ + approved(query) { + query.whereNot('approved_at', null); + }, + /** + * Sorting the estimates orders by delivery status. + */ + orderByStatus(query, order) { + query.orderByRaw(`delivered_at is null ${order}`); + }, + /** + * Filtering the estimates oreders by status field. + */ + filterByStatus(query, filterType) { + switch (filterType) { + case 'draft': + query.modify('draft'); + break; + case 'delivered': + query.modify('delivered'); + break; + case 'approved': + query.modify('approved'); + break; + case 'rejected': + query.modify('rejected'); + break; + case 'invoiced': + query.modify('invoiced'); + break; + case 'expired': + query.modify('expired'); + break; + } + }, + }; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const { ItemEntry } = require('../../Items/models/ItemEntry'); + // const Customer = require('models/Customer'); + // const Branch = require('models/Branch'); + // const Warehouse = require('models/Warehouse'); + // const Document = require('models/Document'); + + return { + // customer: { + // relation: Model.BelongsToOneRelation, + // modelClass: Customer.default, + // join: { + // from: 'sales_estimates.customerId', + // to: 'contacts.id', + // }, + // filter(query) { + // query.where('contact_service', 'customer'); + // }, + // }, + entries: { + relation: Model.HasManyRelation, + modelClass: ItemEntry, + + join: { + from: 'sales_estimates.id', + to: 'items_entries.referenceId', + }, + filter(builder) { + builder.where('reference_type', 'SaleEstimate'); + builder.orderBy('index', 'ASC'); + }, + }, + + // /** + // * Sale estimate may belongs to branch. + // */ + // branch: { + // relation: Model.BelongsToOneRelation, + // modelClass: Branch.default, + // join: { + // from: 'sales_estimates.branchId', + // to: 'branches.id', + // }, + // }, + + // /** + // * Sale estimate may has associated warehouse. + // */ + // warehouse: { + // relation: Model.BelongsToOneRelation, + // modelClass: Warehouse.default, + // join: { + // from: 'sales_estimates.warehouseId', + // to: 'warehouses.id', + // }, + // }, + + // /** + // * Sale estimate transaction may has many attached attachments. + // */ + // attachments: { + // relation: Model.ManyToManyRelation, + // modelClass: Document.default, + // join: { + // from: 'sales_estimates.id', + // through: { + // from: 'document_links.modelId', + // to: 'document_links.documentId', + // }, + // to: 'documents.id', + // }, + // filter(query) { + // query.where('model_ref', 'SaleEstimate'); + // }, + // }, + }; + } + + /** + * Model settings. + */ + // static get meta() { + // return SaleEstimateSettings; + // } + + /** + * Retrieve the default custom views, roles and columns. + */ + // static get defaultViews() { + // return DEFAULT_VIEWS; + // } + + /** + * Model search roles. + */ + static get searchRoles() { + return [ + { fieldKey: 'amount', comparator: 'equals' }, + { condition: 'or', fieldKey: 'estimate_number', comparator: 'contains' }, + { condition: 'or', fieldKey: 'reference_no', comparator: 'contains' }, + ]; + } + + /** + * Prevents mutate base currency since the model is not empty. + */ + static get preventMutateBaseCurrency() { + return true; + } +} diff --git a/packages/server-nest/src/modules/SaleEstimates/queries/GetSaleEstimate.service.ts b/packages/server-nest/src/modules/SaleEstimates/queries/GetSaleEstimate.service.ts new file mode 100644 index 000000000..bd6f5f664 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/queries/GetSaleEstimate.service.ts @@ -0,0 +1,49 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { SaleEstimateTransfromer } from './SaleEstimate.transformer'; +import { SaleEstimateValidators } from '../commands/SaleEstimateValidators.service'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { SaleEstimate } from '../models/SaleEstimate'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; + +@Injectable() +export class GetSaleEstimate { + constructor( + @Inject(SaleEstimate.name) + private readonly saleEstimateModel: typeof SaleEstimate, + private readonly transformer: TransformerInjectable, + private readonly validators: SaleEstimateValidators, + private readonly eventPublisher: EventEmitter2, + ) {} + + /** + * Retrieve the estimate details with associated entries. + * @async + * @param {Integer} estimateId + */ + public async getEstimate(estimateId: number) { + const estimate = await this.saleEstimateModel.query() + .findById(estimateId) + .withGraphFetched('entries.item') + .withGraphFetched('customer') + .withGraphFetched('branch') + .withGraphFetched('attachments'); + + // Validates the estimate existance. + this.validators.validateEstimateExistance(estimate); + + // Transformes sale estimate model to POJO. + const transformed = await this.transformer.transform( + estimate, + new SaleEstimateTransfromer() + ); + const eventPayload = { saleEstimateId: estimateId }; + + // Triggers `onSaleEstimateViewed` event. + await this.eventPublisher.emitAsync( + events.saleEstimate.onViewed, + eventPayload + ); + return transformed; + } +} diff --git a/packages/server-nest/src/modules/SaleEstimates/queries/GetSaleEstimateState.service.ts b/packages/server-nest/src/modules/SaleEstimates/queries/GetSaleEstimateState.service.ts new file mode 100644 index 000000000..aae6613b5 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/queries/GetSaleEstimateState.service.ts @@ -0,0 +1,26 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { PdfTemplateModel } from '@/modules/PdfTemplate/models/PdfTemplate'; +import { ISaleEstimateState } from '../types/SaleEstimates.types'; + +@Injectable() +export class GetSaleEstimateState { + constructor( + @Inject(PdfTemplateModel.name) + private pdfTemplateModel: typeof PdfTemplateModel, + ) {} + + /** + * Retrieves the create/edit sale estimate state. + * @return {Promise} + */ + public async getSaleEstimateState(): Promise { + const defaultPdfTemplate = await this.pdfTemplateModel + .query() + .findOne({ resource: 'SaleEstimate' }) + .modify('default'); + + return { + defaultTemplateId: defaultPdfTemplate?.id, + }; + } +} diff --git a/packages/server-nest/src/modules/SaleEstimates/queries/GetSaleEstimates.ts b/packages/server-nest/src/modules/SaleEstimates/queries/GetSaleEstimates.ts new file mode 100644 index 000000000..a29814130 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/queries/GetSaleEstimates.ts @@ -0,0 +1,79 @@ +// import * as R from 'ramda'; +// import { Inject, Service } from 'typedi'; +// import { +// IFilterMeta, +// IPaginationMeta, +// ISaleEstimate, +// ISalesEstimatesFilter, +// } from '@/interfaces'; +// import HasTenancyService from '@/services/Tenancy/TenancyService'; +// import DynamicListingService from '@/services/DynamicListing/DynamicListService'; +// import { TransformerInjectable } from '@/lib/Transformer/TransformerInjectable'; +// import { SaleEstimateDTOTransformer } from '../commands/SaleEstimateDTOTransformer'; +// import { SaleEstimateTransfromer } from './SaleEstimate.transformer'; + +// @Service() +// export class GetSaleEstimates { +// @Inject() +// private tenancy: HasTenancyService; + +// @Inject() +// private dynamicListService: DynamicListingService; + +// @Inject() +// private transformer: TransformerInjectable; + +// /** +// * Retrieves estimates filterable and paginated list. +// * @param {number} tenantId - +// * @param {IEstimatesFilter} estimatesFilter - +// */ +// public async getEstimates( +// tenantId: number, +// filterDTO: ISalesEstimatesFilter +// ): Promise<{ +// salesEstimates: ISaleEstimate[]; +// pagination: IPaginationMeta; +// filterMeta: IFilterMeta; +// }> { +// const { SaleEstimate } = this.tenancy.models(tenantId); + +// // Parses filter DTO. +// const filter = this.parseListFilterDTO(filterDTO); + +// // Dynamic list service. +// const dynamicFilter = await this.dynamicListService.dynamicList( +// tenantId, +// SaleEstimate, +// filter +// ); +// const { results, pagination } = await SaleEstimate.query() +// .onBuild((builder) => { +// builder.withGraphFetched('customer'); +// builder.withGraphFetched('entries'); +// builder.withGraphFetched('entries.item'); +// dynamicFilter.buildQuery()(builder); +// filterDTO?.filterQuery && filterDTO?.filterQuery(builder); +// }) +// .pagination(filter.page - 1, filter.pageSize); + +// const transformedEstimates = await this.transformer.transform( +// tenantId, +// results, +// new SaleEstimateTransfromer() +// ); +// return { +// salesEstimates: transformedEstimates, +// pagination, +// filterMeta: dynamicFilter.getResponseMeta(), +// }; +// } + +// /** +// * Parses the sale receipts list filter DTO. +// * @param filterDTO +// */ +// private parseListFilterDTO(filterDTO) { +// return R.compose(this.dynamicListService.parseStringifiedFilter)(filterDTO); +// } +// } diff --git a/packages/server-nest/src/modules/SaleEstimates/queries/SaleEstimate.transformer.ts b/packages/server-nest/src/modules/SaleEstimates/queries/SaleEstimate.transformer.ts new file mode 100644 index 000000000..85f177aa0 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/queries/SaleEstimate.transformer.ts @@ -0,0 +1,122 @@ +// import { Transformer } from '@/lib/Transformer/Transformer'; +// import { ItemEntryTransformer } from '../Invoices/ItemEntryTransformer'; +// import { AttachmentTransformer } from '@/services/Attachments/AttachmentTransformer'; + +import { Transformer } from '@/modules/Transformer/Transformer'; +import { SaleEstimate } from '../models/SaleEstimate'; +import { ItemEntryTransformer } from '@/modules/TransactionItemEntry/ItemEntry.transformer'; + +export class SaleEstimateTransfromer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'formattedSubtotal', + 'formattedAmount', + 'formattedEstimateDate', + 'formattedExpirationDate', + 'formattedDeliveredAtDate', + 'formattedApprovedAtDate', + 'formattedRejectedAtDate', + 'formattedCreatedAt', + 'entries', + 'attachments', + ]; + }; + + /** + * Retrieve formatted estimate date. + * @param {ISaleEstimate} invoice + * @returns {String} + */ + protected formattedEstimateDate = (estimate: SaleEstimate): string => { + return this.formatDate(estimate.estimateDate); + }; + + /** + * Retrieve formatted estimate date. + * @param {ISaleEstimate} invoice + * @returns {String} + */ + protected formattedExpirationDate = (estimate: SaleEstimate): string => { + return this.formatDate(estimate.expirationDate); + }; + + /** + * Retrieves the formatted estimate created at. + * @param {ISaleEstimate} estimate - + * @returns {string} + */ + protected formattedCreatedAt = (estimate: SaleEstimate): string => { + return this.formatDate(estimate.createdAt); + }; + + /** + * Retrieve formatted estimate date. + * @param {ISaleEstimate} invoice + * @returns {String} + */ + protected formattedDeliveredAtDate = (estimate: SaleEstimate): string => { + return this.formatDate(estimate.deliveredAt); + }; + + /** + * Retrieve formatted estimate date. + * @param {ISaleEstimate} invoice + * @returns {String} + */ + protected formattedApprovedAtDate = (estimate: SaleEstimate): string => { + return this.formatDate(estimate.approvedAt); + }; + + /** + * Retrieve formatted estimate date. + * @param {ISaleEstimate} invoice + * @returns {String} + */ + protected formattedRejectedAtDate = (estimate: SaleEstimate): string => { + return this.formatDate(estimate.rejectedAt); + }; + + /** + * Retrieve formatted invoice amount. + * @param {ISaleEstimate} estimate + * @returns {string} + */ + protected formattedAmount = (estimate: SaleEstimate): string => { + return this.formatNumber(estimate.amount, { + currencyCode: estimate.currencyCode, + }); + }; + + /** + * Retrieves the formatted invoice subtotal. + * @param {ISaleEstimate} estimate + * @returns {string} + */ + protected formattedSubtotal = (estimate: SaleEstimate): string => { + return this.formatNumber(estimate.amount, { money: false }); + }; + + /** + * Retrieves the entries of the sale estimate. + * @param {ISaleEstimate} estimate + * @returns {} + */ + protected entries = (estimate: SaleEstimate) => { + // return this.item(estimate.entries, new ItemEntryTransformer(), { + // currencyCode: estimate.currencyCode, + // }); + }; + + /** + * Retrieves the sale estimate attachments. + * @param {ISaleInvoice} invoice + * @returns + */ + protected attachments = (estimate: SaleEstimate) => { + // return this.item(estimate.attachments, new AttachmentTransformer()); + }; +} diff --git a/packages/server-nest/src/modules/SaleEstimates/queries/SaleEstimatesPdf.ts b/packages/server-nest/src/modules/SaleEstimates/queries/SaleEstimatesPdf.ts new file mode 100644 index 000000000..751539fd7 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/queries/SaleEstimatesPdf.ts @@ -0,0 +1,116 @@ +// import { Inject, Service } from 'typedi'; +// import { ChromiumlyTenancy } from '@/services/ChromiumlyTenancy/ChromiumlyTenancy'; +// import { TemplateInjectable } from '@/services/TemplateInjectable/TemplateInjectable'; +// import { GetSaleEstimate } from './GetSaleEstimate.service'; +// import HasTenancyService from '@/services/Tenancy/TenancyService'; +// import { SaleEstimatePdfTemplate } from '../Invoices/SaleEstimatePdfTemplate'; +// import { transformEstimateToPdfTemplate } from '../utils'; +// import { EstimatePdfBrandingAttributes } from '../constants'; +// import events from '@/subscribers/events'; +// import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; + +// @Service() +// export class SaleEstimatesPdf { +// @Inject() +// private tenancy: HasTenancyService; + +// @Inject() +// private chromiumlyTenancy: ChromiumlyTenancy; + +// @Inject() +// private templateInjectable: TemplateInjectable; + +// @Inject() +// private getSaleEstimate: GetSaleEstimate; + +// @Inject() +// private estimatePdfTemplate: SaleEstimatePdfTemplate; + +// @Inject() +// private eventPublisher: EventPublisher; + +// /** +// * Retrieve sale invoice pdf content. +// * @param {number} tenantId - +// * @param {ISaleInvoice} saleInvoice - +// */ +// public async getSaleEstimatePdf( +// tenantId: number, +// saleEstimateId: number +// ): Promise<[Buffer, string]> { +// const filename = await this.getSaleEstimateFilename( +// tenantId, +// saleEstimateId +// ); +// const brandingAttributes = await this.getEstimateBrandingAttributes( +// tenantId, +// saleEstimateId +// ); +// const htmlContent = await this.templateInjectable.render( +// tenantId, +// 'modules/estimate-regular', +// brandingAttributes +// ); +// const content = await this.chromiumlyTenancy.convertHtmlContent( +// tenantId, +// htmlContent +// ); +// const eventPayload = { tenantId, saleEstimateId }; + +// // Triggers the `onSaleEstimatePdfViewed` event. +// await this.eventPublisher.emitAsync( +// events.saleEstimate.onPdfViewed, +// eventPayload +// ); +// return [content, filename]; +// } + +// /** +// * Retrieves the filename file document of the given estimate. +// * @param {number} tenantId +// * @param {number} estimateId +// * @returns {Promise} +// */ +// private async getSaleEstimateFilename(tenantId: number, estimateId: number) { +// const { SaleEstimate } = this.tenancy.models(tenantId); + +// const estimate = await SaleEstimate.query().findById(estimateId); + +// return `Estimate-${estimate.estimateNumber}`; +// } + +// /** +// * Retrieves the given estimate branding attributes. +// * @param {number} tenantId - Tenant id. +// * @param {number} estimateId - Estimate id. +// * @returns {Promise} +// */ +// async getEstimateBrandingAttributes( +// tenantId: number, +// estimateId: number +// ): Promise { +// const { PdfTemplate } = this.tenancy.models(tenantId); +// const saleEstimate = await this.getSaleEstimate.getEstimate( +// tenantId, +// estimateId +// ); +// // Retrieve the invoice template id of not found get the default template id. +// const templateId = +// saleEstimate.pdfTemplateId ?? +// ( +// await PdfTemplate.query().findOne({ +// resource: 'SaleEstimate', +// default: true, +// }) +// )?.id; +// const brandingTemplate = +// await this.estimatePdfTemplate.getEstimatePdfTemplate( +// tenantId, +// templateId +// ); +// return { +// ...brandingTemplate.attributes, +// ...transformEstimateToPdfTemplate(saleEstimate), +// }; +// } +// } diff --git a/packages/server-nest/src/modules/SaleEstimates/subscribers/SaleEstimateMarkApprovedOnMailSent.ts b/packages/server-nest/src/modules/SaleEstimates/subscribers/SaleEstimateMarkApprovedOnMailSent.ts new file mode 100644 index 000000000..dab3ffede --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/subscribers/SaleEstimateMarkApprovedOnMailSent.ts @@ -0,0 +1,35 @@ +// import { ERRORS } from '../constants'; +// import { OnEvent } from '@nestjs/event-emitter'; +// import { Injectable } from '@nestjs/common'; +// import { DeliverSaleEstimateService } from '../commands/DeliverSaleEstimate.service'; +// import { events } from '@/common/events/events'; +// import { ISaleEstimateMailPresendEvent } from '../types/SaleEstimates.types'; +// import { ServiceError } from '@/modules/Items/ServiceError'; + +// @Injectable() +// export class SaleEstimateMarkApprovedOnMailSentSubscriber { +// constructor( +// private readonly deliverEstimateService: DeliverSaleEstimateService, +// ) {} + +// /** +// * Marks the given estimate approved on submitting mail. +// * @param {ISaleEstimateMailPresendEvent} +// */ +// @OnEvent(events.saleEstimate.onPreMailSend) +// public async markEstimateApproved({ +// saleEstimateId, +// }: ISaleEstimateMailPresendEvent) { +// try { +// await this.deliverEstimateService.deliverSaleEstimate(saleEstimateId); +// } catch (error) { +// if ( +// error instanceof ServiceError && +// error.errorType === ERRORS.SALE_ESTIMATE_ALREADY_DELIVERED +// ) { +// } else { +// throw error; +// } +// } +// } +// } diff --git a/packages/server-nest/src/modules/SaleEstimates/types/SaleEstimates.types.ts b/packages/server-nest/src/modules/SaleEstimates/types/SaleEstimates.types.ts new file mode 100644 index 000000000..a10b3eeca --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/types/SaleEstimates.types.ts @@ -0,0 +1,122 @@ +import { Knex } from 'knex'; +// import { CommonMailOptions, CommonMailOptionsDTO } from './Mailable'; +// import { AttachmentLinkDTO } from './Attachments'; +import { SaleEstimate } from '../models/SaleEstimate'; +import { IItemEntryDTO } from '@/modules/TransactionItemEntry/ItemEntry.types'; +import { AttachmentLinkDTO } from '@/modules/Attachments/Attachments.types'; + +export interface ISaleEstimateDTO { + customerId: number; + exchangeRate?: number; + estimateDate?: Date; + reference?: string; + estimateNumber?: string; + entries: IItemEntryDTO[]; + note: string; + termsConditions: string; + sendToEmail: string; + delivered: boolean; + + branchId?: number; + warehouseId?: number; + attachments?: AttachmentLinkDTO[]; +} + +// export interface ISalesEstimatesFilter extends IDynamicListFilterDTO { +// stringifiedFilterRoles?: string; +// filterQuery?: (q: any) => void; +// } + +export interface ISaleEstimateCreatedPayload { + // tenantId: number; + saleEstimate: SaleEstimate; + saleEstimateId: number; + saleEstimateDTO: ISaleEstimateDTO; + trx?: Knex.Transaction; +} + +export interface ISaleEstimateCreatingPayload { + estimateDTO: ISaleEstimateDTO; + tenantId: number; + trx: Knex.Transaction; +} + +export interface ISaleEstimateEditedPayload { + // tenantId: number; + estimateId: number; + saleEstimate: SaleEstimate; + oldSaleEstimate: SaleEstimate; + estimateDTO: ISaleEstimateDTO; + trx?: Knex.Transaction; +} + +export interface ISaleEstimateEditingPayload { + // tenantId: number; + oldSaleEstimate: SaleEstimate; + estimateDTO: ISaleEstimateDTO; + trx: Knex.Transaction; +} + +export interface ISaleEstimateDeletedPayload { + // tenantId: number; + saleEstimateId: number; + oldSaleEstimate: SaleEstimate; + trx: Knex.Transaction; +} + +export interface ISaleEstimateDeletingPayload { + // tenantId: number; + oldSaleEstimate: SaleEstimate; + trx: Knex.Transaction; +} + +export interface ISaleEstimateEventDeliveredPayload { + // tenantId: number; + saleEstimate: SaleEstimate; + trx: Knex.Transaction; +} + +export interface ISaleEstimateEventDeliveringPayload { + // tenantId: number; + oldSaleEstimate: SaleEstimate; + trx: Knex.Transaction; +} + +export enum SaleEstimateAction { + Create = 'Create', + Edit = 'Edit', + Delete = 'Delete', + View = 'View', + NotifyBySms = 'NotifyBySms', +} + +export interface ISaleEstimateApprovingEvent { + // tenantId: number; + oldSaleEstimate: SaleEstimate; + trx: Knex.Transaction; +} + +export interface ISaleEstimateApprovedEvent { + // tenantId: number; + oldSaleEstimate: SaleEstimate; + saleEstimate: SaleEstimate; + trx: Knex.Transaction; +} + +// export interface SaleEstimateMailOptions extends CommonMailOptions { +// attachEstimate?: boolean; +// } + +// export interface SaleEstimateMailOptionsDTO extends CommonMailOptionsDTO { +// attachEstimate?: boolean; +// } + +// export interface ISaleEstimateMailPresendEvent { +// // tenantId: number; +// saleEstimateId: number; +// messageOptions: SaleEstimateMailOptionsDTO; +// } + +export interface ISaleEstimateState { + defaultTemplateId: number; +} diff --git a/packages/server-nest/src/modules/SaleEstimates/utils.ts b/packages/server-nest/src/modules/SaleEstimates/utils.ts new file mode 100644 index 000000000..efd301af3 --- /dev/null +++ b/packages/server-nest/src/modules/SaleEstimates/utils.ts @@ -0,0 +1,34 @@ +import { contactAddressTextFormat } from '@/utils/address-text-format'; +import { EstimatePdfBrandingAttributes } from './constants'; + +export const transformEstimateToPdfTemplate = ( + estimate +): Partial => { + return { + expirationDate: estimate.formattedExpirationDate, + estimateNumebr: estimate.estimateNumber, + estimateDate: estimate.formattedEstimateDate, + lines: estimate.entries.map((entry) => ({ + item: entry.item.name, + description: entry.description, + rate: entry.rateFormatted, + quantity: entry.quantityFormatted, + total: entry.totalFormatted, + })), + total: estimate.formattedSubtotal, + subtotal: estimate.formattedSubtotal, + customerNote: estimate.note, + termsConditions: estimate.termsConditions, + customerAddress: contactAddressTextFormat(estimate.customer), + }; +}; + +export const transformEstimateToMailDataArgs = (estimate: any) => { + return { + 'Customer Name': estimate.customer.displayName, + 'Estimate Number': estimate.estimateNumber, + 'Estimate Date': estimate.formattedEstimateDate, + 'Estimate Amount': estimate.formattedAmount, + 'Estimate Expiration Date': estimate.formattedExpirationDate, + }; +}; diff --git a/packages/server-nest/src/modules/System/SystemModels/SystemModels.module.ts b/packages/server-nest/src/modules/System/SystemModels/SystemModels.module.ts index aad867cd5..1a55ebbe3 100644 --- a/packages/server-nest/src/modules/System/SystemModels/SystemModels.module.ts +++ b/packages/server-nest/src/modules/System/SystemModels/SystemModels.module.ts @@ -11,7 +11,6 @@ import { TenantMetadata } from '../models/TenantMetadataModel'; const models = [SystemUser, PlanSubscription, TenantModel, TenantMetadata]; const modelProviders = models.map((model) => { - console.log(model.name, model, 'model.name'); return { provide: model.name, useValue: model, diff --git a/packages/server-nest/src/modules/TaxRates/utils.ts b/packages/server-nest/src/modules/TaxRates/utils.ts new file mode 100644 index 000000000..15d9e9d36 --- /dev/null +++ b/packages/server-nest/src/modules/TaxRates/utils.ts @@ -0,0 +1,19 @@ +/** + * Get inclusive tax amount. + * @param {number} amount + * @param {number} taxRate + * @returns {number} + */ +export const getInclusiveTaxAmount = (amount: number, taxRate: number) => { + return (amount * taxRate) / (100 + taxRate); +}; + +/** + * Get exclusive tax amount. + * @param {number} amount + * @param {number} taxRate + * @returns {number} + */ +export const getExlusiveTaxAmount = (amount: number, taxRate: number) => { + return (amount * taxRate) / 100; +}; diff --git a/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts b/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts index 8832e5cd2..ec31ca2b2 100644 --- a/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts +++ b/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts @@ -14,6 +14,9 @@ import { PdfTemplateModel } from '@/modules/PdfTemplate/models/PdfTemplate'; import { Warehouse } from '@/modules/Warehouses/models/Warehouse.model'; import { ItemWarehouseQuantity } from '@/modules/Warehouses/models/ItemWarehouseQuantity'; import { Branch } from '@/modules/Branches/models/Branch.model'; +import { SaleEstimate } from '@/modules/SaleEstimates/models/SaleEstimate'; +import { Customer } from '@/modules/Customers/models/Customer'; +import { Contact } from '@/modules/Contacts/models/Contact'; const models = [ Item, @@ -28,6 +31,9 @@ const models = [ Warehouse, ItemWarehouseQuantity, Branch, + SaleEstimate, + Customer, + Contact ]; const modelProviders = models.map((model) => { diff --git a/packages/server-nest/src/modules/TransactionItemEntry/ItemEntry.transformer.ts b/packages/server-nest/src/modules/TransactionItemEntry/ItemEntry.transformer.ts new file mode 100644 index 000000000..52c186f7c --- /dev/null +++ b/packages/server-nest/src/modules/TransactionItemEntry/ItemEntry.transformer.ts @@ -0,0 +1,49 @@ +import { Transformer } from '../Transformer/Transformer'; +import { ItemEntry } from './models/ItemEntry'; + +interface ItemEntryTransformerContext{ + currencyCode: string; +} + +export class ItemEntryTransformer extends Transformer<{}, ItemEntryTransformerContext> { + /** + * Include these attributes to item entry object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return ['quantityFormatted', 'rateFormatted', 'totalFormatted']; + }; + + /** + * Retrieves the formatted quantitty of item entry. + * @param {IItemEntry} entry + * @returns {string} + */ + protected quantityFormatted = (entry: ItemEntry): string => { + return this.formatNumber(entry.quantity, { money: false }); + }; + + /** + * Retrieves the formatted rate of item entry. + * @param {IItemEntry} itemEntry - + * @returns {string} + */ + protected rateFormatted = (entry: ItemEntry): string => { + return this.formatNumber(entry.rate, { + currencyCode: this.context.currencyCode, + money: false, + }); + }; + + /** + * Retrieves the formatted total of item entry. + * @param {IItemEntry} entry + * @returns {string} + */ + protected totalFormatted = (entry: ItemEntry): string => { + return this.formatNumber(entry.total, { + currencyCode: this.context.currencyCode, + money: false, + }); + }; +} diff --git a/packages/server-nest/src/modules/TransactionItemEntry/ItemEntry.types.ts b/packages/server-nest/src/modules/TransactionItemEntry/ItemEntry.types.ts new file mode 100644 index 000000000..772fd223d --- /dev/null +++ b/packages/server-nest/src/modules/TransactionItemEntry/ItemEntry.types.ts @@ -0,0 +1,22 @@ +export type IItemEntryTransactionType = 'SaleInvoice' | 'Bill' | 'SaleReceipt'; + +export interface IItemEntryDTO { + id?: number; + index?: number; + itemId: number; + landedCost?: boolean; + warehouseId?: number; + + projectRefId?: number; + projectRefType?: ProjectLinkRefType; + projectRefInvoicedAmount?: number; + + taxRateId?: number; + taxCode?: string; +} + +export enum ProjectLinkRefType { + Task = 'TASK', + Bill = 'BILL', + Expense = 'EXPENSE', +} diff --git a/packages/server-nest/src/modules/TransactionItemEntry/models/ItemEntry.ts b/packages/server-nest/src/modules/TransactionItemEntry/models/ItemEntry.ts new file mode 100644 index 000000000..2e7ce0430 --- /dev/null +++ b/packages/server-nest/src/modules/TransactionItemEntry/models/ItemEntry.ts @@ -0,0 +1,244 @@ +import { Model } from 'objection'; +import { BaseModel } from '@/models/Model'; +import { + getExlusiveTaxAmount, + getInclusiveTaxAmount, +} from '@/modules/TaxRates/utils'; + +export class ItemEntry extends BaseModel { + public referenceType: string; + public referenceId: string; + + public index: number; + public itemId: number; + public description: string; + + public sellAccountId: number; + public costAccountId: number; + + public landedCost: boolean; + public allocatedCostAmount: number; + 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'; + } + + /** + * Timestamps columns. + * @returns {string[]} + */ + get timestamps() { + return ['created_at', 'updated_at']; + } + + /** + * Virtual attributes. + * @returns {string[]} + */ + static get virtualAttributes() { + 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 this.quantity * this.rate; + } + + /** + * Item entry amount including tax. + * @returns {number} + */ + get amountInclusingTax() { + return this.isInclusiveTax ? this.amount : this.amount + this.taxAmount; + } + + /** + * Item entry amount excluding tax. + * @returns {number} + */ + get amountExludingTax() { + return this.isInclusiveTax ? this.amount - this.taxAmount : this.amount; + } + + /** + * Discount amount. + * @returns {number} + */ + get discountAmount() { + return this.amount * (this.discount / 100); + } + + /** + * Tag rate fraction. + * @returns {number} + */ + get tagRateFraction() { + return this.taxRate / 100; + } + + /** + * Tax amount withheld. + * @returns {number} + */ + get taxAmount() { + return this.isInclusiveTax + ? getInclusiveTaxAmount(this.amount, this.taxRate) + : getExlusiveTaxAmount(this.amount, this.taxRate); + } + + static calcAmount(itemEntry) { + const { discount, quantity, rate } = itemEntry; + const total = quantity * rate; + + return discount ? total - total * discount * 0.01 : total; + } + + /** + * Item entry relations. + */ + static get relationMappings() { + const Item = require('models/Item'); + const BillLandedCostEntry = require('models/BillLandedCostEntry'); + const SaleInvoice = require('models/SaleInvoice'); + const Bill = require('models/Bill'); + const SaleReceipt = require('models/SaleReceipt'); + const SaleEstimate = require('models/SaleEstimate'); + const ProjectTask = require('models/Task'); + const Expense = require('models/Expense'); + const TaxRate = require('models/TaxRate'); + + return { + item: { + relation: Model.BelongsToOneRelation, + modelClass: Item.default, + join: { + from: 'items_entries.itemId', + to: 'items.id', + }, + }, + allocatedCostEntries: { + relation: Model.HasManyRelation, + modelClass: BillLandedCostEntry.default, + join: { + from: 'items_entries.referenceId', + to: 'bill_located_cost_entries.entryId', + }, + }, + + invoice: { + relation: Model.BelongsToOneRelation, + modelClass: SaleInvoice.default, + join: { + from: 'items_entries.referenceId', + to: 'sales_invoices.id', + }, + }, + + bill: { + relation: Model.BelongsToOneRelation, + modelClass: Bill.default, + join: { + from: 'items_entries.referenceId', + to: 'bills.id', + }, + }, + + estimate: { + relation: Model.BelongsToOneRelation, + modelClass: SaleEstimate.default, + join: { + from: 'items_entries.referenceId', + to: 'sales_estimates.id', + }, + }, + + /** + * Sale receipt reference. + */ + receipt: { + relation: Model.BelongsToOneRelation, + modelClass: SaleReceipt.default, + join: { + from: 'items_entries.referenceId', + to: 'sales_receipts.id', + }, + }, + + /** + * Project task reference. + */ + projectTaskRef: { + relation: Model.HasManyRelation, + modelClass: ProjectTask.default, + join: { + from: 'items_entries.projectRefId', + to: 'tasks.id', + }, + }, + + /** + * Project expense reference. + */ + projectExpenseRef: { + relation: Model.HasManyRelation, + modelClass: Expense.default, + join: { + from: 'items_entries.projectRefId', + to: 'expenses_transactions.id', + }, + }, + + /** + * Project bill reference. + */ + projectBillRef: { + relation: Model.HasManyRelation, + modelClass: Bill.default, + join: { + from: 'items_entries.projectRefId', + 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-nest/src/modules/Transformer/Transformer.ts b/packages/server-nest/src/modules/Transformer/Transformer.ts index 488f2bc2b..c492a904c 100644 --- a/packages/server-nest/src/modules/Transformer/Transformer.ts +++ b/packages/server-nest/src/modules/Transformer/Transformer.ts @@ -7,8 +7,8 @@ import { TransformerContext } from './Transformer.types'; const EXPORT_DTE_FORMAT = 'YYYY-MM-DD'; -export class Transformer { - public context: TransformerContext; +export class Transformer { + public context: ExtraContext & TransformerContext; public options: Record; /** @@ -88,7 +88,7 @@ export class Transformer { // sortObjectKeysAlphabetically, this.transform, R.when(this.hasExcludeAttributes, this.excludeAttributesTransformed), - this.includeAttributesTransformed + this.includeAttributesTransformed, )(normlizedItem); }; @@ -119,7 +119,7 @@ export class Transformer { return attributes .filter( (attribute) => - isFunction(this[attribute]) || !isUndefined(item[attribute]) + isFunction(this[attribute]) || !isUndefined(item[attribute]), ) .reduce((acc, attribute: string) => { acc[attribute] = isFunction(this[attribute]) @@ -216,7 +216,7 @@ export class Transformer { public item( obj: Record, transformer: Transformer, - options?: any + options?: any, ) { transformer.setOptions(options); transformer.setContext(this.context); diff --git a/packages/server-nest/src/modules/Warehouses/Integrations/WarehouseTransactionDTOTransform.ts b/packages/server-nest/src/modules/Warehouses/Integrations/WarehouseTransactionDTOTransform.ts index b73f69d32..82f2b5c8e 100644 --- a/packages/server-nest/src/modules/Warehouses/Integrations/WarehouseTransactionDTOTransform.ts +++ b/packages/server-nest/src/modules/Warehouses/Integrations/WarehouseTransactionDTOTransform.ts @@ -1,38 +1,37 @@ -// import { Service, Inject } from 'typedi'; -// import { omit } from 'lodash'; -// import * as R from 'ramda'; -// import { WarehousesSettings } from '../WarehousesSettings'; +import { omit } from 'lodash'; +import { Injectable } from '@nestjs/common'; +import { WarehousesSettings } from '../WarehousesSettings'; -// @Service() -// export class WarehouseTransactionDTOTransform { -// @Inject() -// private warehousesSettings: WarehousesSettings; +@Injectable() +export class WarehouseTransactionDTOTransform { + constructor( + private readonly warehousesSettings: WarehousesSettings, + ) {} -// /** -// * Excludes DTO warehouse id when mutli-warehouses feature is inactive. -// * @param {number} tenantId -// * @returns {Promise | T>} -// */ -// private excludeDTOWarehouseIdWhenInactive = < -// T extends { warehouseId?: number } -// >( -// tenantId: number, -// DTO: T -// ): Omit | T => { -// const isActive = this.warehousesSettings.isMultiWarehousesActive(tenantId); + /** + * Excludes DTO warehouse id when mutli-warehouses feature is inactive. + * @param {number} tenantId + * @returns {Promise | T>} + */ + private excludeDTOWarehouseIdWhenInactive = < + T extends { warehouseId?: number } + >( + DTO: T + ): Omit | T => { + const isActive = this.warehousesSettings.isMultiWarehousesActive(); -// return !isActive ? omit(DTO, ['warehouseId']) : DTO; -// }; + return !isActive ? omit(DTO, ['warehouseId']) : DTO; + }; -// /** -// * -// * @param {number} tenantId -// * @param {T} DTO - -// * @returns {Omit | T} -// */ -// public transformDTO = -// (tenantId: number) => -// (DTO: T): Omit | T => { -// return this.excludeDTOWarehouseIdWhenInactive(tenantId, DTO); -// }; -// } + /** + * + * @param {number} tenantId + * @param {T} DTO - + * @returns {Omit | T} + */ + public transformDTO = + (DTO: T): Omit | T => { + return this.excludeDTOWarehouseIdWhenInactive(DTO); + }; +} + \ No newline at end of file diff --git a/packages/server-nest/src/utils/address-text-format.ts b/packages/server-nest/src/utils/address-text-format.ts new file mode 100644 index 000000000..3813f7e04 --- /dev/null +++ b/packages/server-nest/src/utils/address-text-format.ts @@ -0,0 +1,113 @@ +import { Contact } from "@/modules/Contacts/models/Contact"; + +interface OrganizationAddressFormatArgs { + organizationName?: string; + address1?: string; + address2?: string; + state?: string; + city?: string; + country?: string; + postalCode?: string; + phone?: string; +} + +export const defaultOrganizationAddressFormat = ` +{ORGANIZATION_NAME} +{ADDRESS_1} +{ADDRESS_2} +{CITY} {STATE} {POSTAL_CODE} +{COUNTRY} +{PHONE} +`; +/** + * Formats the address text based on the provided message and arguments. + * This function replaces placeholders in the message with actual values + * from the OrganizationAddressFormatArgs. It ensures that the final + * formatted message is clean and free of excessive newlines. + * + * @param {string} message - The message template containing placeholders. + * @param {Record} args - The arguments containing the values to replace in the message. + * @returns {string} - The formatted address text. + */ +const formatText = (message: string, replacements: Record) => { + let formattedMessage = Object.entries(replacements).reduce( + (msg, [key, value]) => { + return msg.split(`{${key}}`).join(value || ''); + }, + message + ); + // Removes any empty lines. + formattedMessage = formattedMessage.replace(/^\s*[\r\n]/gm, ''); + formattedMessage = formattedMessage.replace(/\n{2,}/g, '\n'); + formattedMessage = formattedMessage.replace(/\n/g, '
'); + formattedMessage = formattedMessage.trim(); + + return formattedMessage; +}; + +export const organizationAddressTextFormat = ( + message: string, + args: OrganizationAddressFormatArgs +) => { + const replacements: Record = { + ORGANIZATION_NAME: args.organizationName || '', + ADDRESS_1: args.address1 || '', + ADDRESS_2: args.address2 || '', + CITY: args.city || '', + STATE: args.state || '', + POSTAL_CODE: args.postalCode || '', + COUNTRY: args.country || '', + PHONE: args.phone || '', + }; + return formatText(message, replacements); +}; + +interface ContactAddressTextFormatArgs { + displayName?: string; + state?: string; + postalCode?: string; + email?: string; + country?: string; + city?: string; + address2?: string; + address1?: string; + phone?: string; +} + +export const defaultContactAddressFormat = `{CONTACT_NAME} +{ADDRESS_1} +{ADDRESS_2} +{CITY} {STATE} {POSTAL_CODE} +{COUNTRY} +{PHONE} +`; + +export const contactAddressTextFormat = ( + contact: Contact, + message: string = defaultContactAddressFormat +) => { + const args = { + displayName: contact.displayName, + address1: contact.billingAddress1, + address2: contact.billingAddress2, + state: contact.billingAddressState, + country: contact.billingAddressCountry, + postalCode: contact?.billingAddressPostcode, + city: contact?.billingAddressCity, + email: contact?.email, + phone: contact?.billingAddressPhone, + } as ContactAddressTextFormatArgs; + + const replacements: Record = { + CONTACT_NAME: args.displayName || '', + ADDRESS_1: args.address1 || '', + ADDRESS_2: args.address2 || '', + CITY: args.city || '', + STATE: args.state || '', + POSTAL_CODE: args.postalCode || '', + COUNTRY: args.country || '', + EMAIL: args?.email || '', + PHONE: args?.phone || '', + }; + return formatText(message, replacements); +}; diff --git a/packages/server-nest/src/utils/format-date-fields.ts b/packages/server-nest/src/utils/format-date-fields.ts new file mode 100644 index 000000000..5316fbf73 --- /dev/null +++ b/packages/server-nest/src/utils/format-date-fields.ts @@ -0,0 +1,23 @@ +import * as moment from 'moment'; + +/** + * Formats the given date fields. + * @param {any} inputDTO - Input data. + * @param {Array} fields - Fields to format. + * @param {string} format - Format string. + * @returns {any} + */ +export const formatDateFields = ( + inputDTO: any, + fields: Array, + format = 'YYYY-MM-DD', +) => { + const _inputDTO = { ...inputDTO }; + + fields.forEach((field) => { + if (_inputDTO[field]) { + _inputDTO[field] = moment(_inputDTO[field]).format(format); + } + }); + return _inputDTO; +}; diff --git a/packages/server-nest/test/sale-estimates.e2e-spec.ts b/packages/server-nest/test/sale-estimates.e2e-spec.ts new file mode 100644 index 000000000..93eb8a3a9 --- /dev/null +++ b/packages/server-nest/test/sale-estimates.e2e-spec.ts @@ -0,0 +1,61 @@ +import * as request from 'supertest'; +import { faker } from '@faker-js/faker'; +import { app } from './init-app-test'; + +describe('Sale Estimates (e2e)', () => { + it('/sales/estimates (POST)', async () => { + return request(app.getHttpServer()) + .post('/sales/estimates') + .set('organization-id', '4064541lv40nhca') + .send({ + customerId: 2, + estimateDate: '2022-02-02', + expirationDate: '2020-03-02', + delivered: false, + estimateNumber: faker.string.uuid(), + discount: 100, + discountType: 'amount', + entries: [ + { + index: 1, + itemId: 1001, + quantity: 3, + rate: 1000, + description: "It's description here.", + }, + ], + }) + .expect(201); + }); + + it('/sales/estimates (DELETE)', async () => { + const response = await request(app.getHttpServer()) + .post('/sales/estimates') + .set('organization-id', '4064541lv40nhca') + .send({ + customerId: 2, + estimateDate: '2022-02-02', + expirationDate: '2020-03-02', + delivered: false, + estimateNumber: faker.string.uuid(), + discount: 100, + discountType: 'amount', + entries: [ + { + index: 1, + itemId: 1001, + quantity: 3, + rate: 1000, + description: "It's description here.", + }, + ], + }); + + const estimateId = response.body.id; + + return request(app.getHttpServer()) + .delete(`/sales/estimates/${estimateId}`) + .set('organization-id', '4064541lv40nhca') + .expect(200); + }); +}); diff --git a/packages/server/src/services/Items/ItemsEntriesService.ts b/packages/server/src/services/Items/ItemsEntriesService.ts index bab37c367..e0f3c8745 100644 --- a/packages/server/src/services/Items/ItemsEntriesService.ts +++ b/packages/server/src/services/Items/ItemsEntriesService.ts @@ -1,11 +1,10 @@ +import { Injectable, Inject } from '@nestjs/common'; +import { Knex } from 'knex'; import { sumBy, difference, map } from 'lodash'; -import { Inject, Service } from 'typedi'; import { IItemEntry, IItemEntryDTO, IItem } from '@/interfaces'; import { ServiceError } from '@/exceptions'; -import TenancyService from '@/services/Tenancy/TenancyService'; -import { ItemEntry } from '@/models'; +import { Item, ItemEntry } from '@/models'; import { entriesAmountDiff } from 'utils'; -import { Knex } from 'knex'; const ERRORS = { ITEMS_NOT_FOUND: 'ITEMS_NOT_FOUND', @@ -14,38 +13,36 @@ const ERRORS = { NOT_SELL_ABLE_ITEMS: 'NOT_SELL_ABLE_ITEMS', }; -@Service() +@Injectable() export default class ItemsEntriesService { - @Inject() - private tenancy: TenancyService; + constructor( + @Inject(Item.name) + private readonly itemModel: typeof Item, + @Inject(ItemEntry.name) + private readonly itemEntryModel: typeof ItemEntry, + private readonly itemRepository: any, // Replace 'any' with proper repository type + ) {} /** * Retrieve the inventory items entries of the reference id and type. - * @param {number} tenantId * @param {string} referenceType * @param {string} referenceId * @return {Promise} */ public async getInventoryEntries( - tenantId: number, referenceType: string, referenceId: number ): Promise { - const { Item, ItemEntry } = this.tenancy.models(tenantId); - - const itemsEntries = await ItemEntry.query() + const itemsEntries = await this.itemEntryModel.query() .where('reference_type', referenceType) .where('reference_id', referenceId); - // Inventory items. - const inventoryItems = await Item.query() + const inventoryItems = await this.itemModel.query() .whereIn('id', map(itemsEntries, 'itemId')) .where('type', 'inventory'); - // Inventory items ids. const inventoryItemsIds = map(inventoryItems, 'id'); - // Filtering the inventory items entries. const inventoryItemsEntries = itemsEntries.filter( (itemEntry) => inventoryItemsIds.indexOf(itemEntry.itemId) !== -1 ); @@ -58,15 +55,12 @@ export default class ItemsEntriesService { * @returns {IItemEntry[]} */ public async filterInventoryEntries( - tenantId: number, entries: IItemEntry[], trx?: Knex.Transaction ): Promise { - const { Item } = this.tenancy.models(tenantId); const entriesItemsIds = entries.map((e) => e.itemId); - // Retrieve entries inventory items. - const inventoryItems = await Item.query(trx) + const inventoryItems = await this.itemModel.query(trx) .whereIn('id', entriesItemsIds) .where('type', 'inventory'); @@ -79,17 +73,14 @@ export default class ItemsEntriesService { /** * Validates the entries items ids. * @async - * @param {number} tenantId - - * @param {IItemEntryDTO} itemEntries - + * @param {IItemEntryDTO[]} itemEntries - */ public async validateItemsIdsExistance( - tenantId: number, itemEntries: IItemEntryDTO[] ) { - const { Item } = this.tenancy.models(tenantId); const itemsIds = itemEntries.map((e) => e.itemId); - const foundItems = await Item.query().whereIn('id', itemsIds); + const foundItems = await this.itemModel.query().whereIn('id', itemsIds); const foundItemsIds = foundItems.map((item: IItem) => item.id); const notFoundItemsIds = difference(itemsIds, foundItemsIds); @@ -102,22 +93,19 @@ export default class ItemsEntriesService { /** * Validates the entries ids existance on the storage. - * @param {number} tenantId - * @param {number} billId - * @param {IItemEntry[]} billEntries - */ public async validateEntriesIdsExistance( - tenantId: number, referenceId: number, referenceType: string, billEntries: IItemEntryDTO[] ) { - const { ItemEntry } = this.tenancy.models(tenantId); const entriesIds = billEntries .filter((e: IItemEntry) => e.id) .map((e: IItemEntry) => e.id); - const storedEntries = await ItemEntry.query() + const storedEntries = await this.itemEntryModel.query() .whereIn('reference_id', [referenceId]) .whereIn('reference_type', [referenceType]); @@ -133,13 +121,11 @@ export default class ItemsEntriesService { * Validate the entries items that not purchase-able. */ public async validateNonPurchasableEntriesItems( - tenantId: number, itemEntries: IItemEntryDTO[] ) { - const { Item } = this.tenancy.models(tenantId); const itemsIds = itemEntries.map((e: IItemEntryDTO) => e.itemId); - const purchasbleItems = await Item.query() + const purchasbleItems = await this.itemModel.query() .where('purchasable', true) .whereIn('id', itemsIds); @@ -155,13 +141,11 @@ export default class ItemsEntriesService { * Validate the entries items that not sell-able. */ public async validateNonSellableEntriesItems( - tenantId: number, itemEntries: IItemEntryDTO[] ) { - const { Item } = this.tenancy.models(tenantId); const itemsIds = itemEntries.map((e: IItemEntryDTO) => e.itemId); - const sellableItems = await Item.query() + const sellableItems = await this.itemModel.query() .where('sellable', true) .whereIn('id', itemsIds); @@ -175,16 +159,13 @@ export default class ItemsEntriesService { /** * Changes items quantity from the given items entries the new and old onces. - * @param {number} tenantId - * @param {IItemEntry} entries - Items entries. - * @param {IItemEntry} oldEntries - Old items entries. + * @param {IItemEntry[]} entries - Items entries. + * @param {IItemEntry[]} oldEntries - Old items entries. */ public async changeItemsQuantity( - tenantId: number, entries: IItemEntry[], oldEntries?: IItemEntry[] ): Promise { - const { itemRepository } = this.tenancy.repositories(tenantId); const opers = []; const diffEntries = entriesAmountDiff( @@ -194,7 +175,7 @@ export default class ItemsEntriesService { 'itemId' ); diffEntries.forEach((entry: IItemEntry) => { - const changeQuantityOper = itemRepository.changeNumber( + const changeQuantityOper = this.itemRepository.changeNumber( { id: entry.itemId, type: 'inventory' }, 'quantityOnHand', entry.quantity @@ -206,27 +187,22 @@ export default class ItemsEntriesService { /** * Increment items quantity from the given items entries. - * @param {number} tenantId - Tenant id. - * @param {IItemEntry} entries - Items entries. + * @param {IItemEntry[]} entries - Items entries. */ public async incrementItemsEntries( - tenantId: number, entries: IItemEntry[] ): Promise { - return this.changeItemsQuantity(tenantId, entries); + return this.changeItemsQuantity(entries); } /** * Decrement items quantity from the given items entries. - * @param {number} tenantId - Tenant id. - * @param {IItemEntry} entries - Items entries. + * @param {IItemEntry[]} entries - Items entries. */ public async decrementItemsQuantity( - tenantId: number, entries: IItemEntry[] ): Promise { return this.changeItemsQuantity( - tenantId, entries.map((entry) => ({ ...entry, quantity: entry.quantity * -1, @@ -237,12 +213,10 @@ export default class ItemsEntriesService { /** * Sets the cost/sell accounts to the invoice entries. */ - public setItemsEntriesDefaultAccounts(tenantId: number) { - return async (entries: IItemEntry[]) => { - const { Item } = this.tenancy.models(tenantId); - + public setItemsEntriesDefaultAccounts() { + return async (entries: IItemEntry[]) => { const entriesItemsIds = entries.map((e) => e.itemId); - const items = await Item.query().whereIn('id', entriesItemsIds); + const items = await this.itemModel.query().whereIn('id', entriesItemsIds); return entries.map((entry) => { const item = items.find((i) => i.id === entry.itemId); diff --git a/src/errors/service-error.ts b/src/errors/service-error.ts new file mode 100644 index 000000000..4125440fa --- /dev/null +++ b/src/errors/service-error.ts @@ -0,0 +1,14 @@ +import { HttpStatus } from '@nestjs/common'; + +export class ServiceError extends Error { + constructor( + public message: string, + private status: HttpStatus = HttpStatus.INTERNAL_SERVER_ERROR, + ) { + super(message); + } + + getStatus(): HttpStatus { + return this.status; + } +} \ No newline at end of file diff --git a/src/filters/service-error.filter.ts b/src/filters/service-error.filter.ts new file mode 100644 index 000000000..6542ee4d5 --- /dev/null +++ b/src/filters/service-error.filter.ts @@ -0,0 +1,19 @@ +import { ExceptionFilter, Catch, ArgumentsHost, HttpStatus } from '@nestjs/common'; +import { Response } from 'express'; +import { ServiceError } from '../errors/service-error'; + +@Catch(ServiceError) +export class ServiceErrorFilter implements ExceptionFilter { + catch(exception: ServiceError, host: ArgumentsHost) { + const ctx = host.switchToHttp(); + const response = ctx.getResponse(); + const status = exception.getStatus(); + + response + .status(status) + .json({ + statusCode: status, + message: exception.message, + }); + } +} \ No newline at end of file diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 000000000..c077060b0 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,13 @@ +import { NestFactory } from '@nestjs/core'; +import { AppModule } from './app.module'; +import { ServiceErrorFilter } from './filters/service-error.filter'; + +async function bootstrap() { + const app = await NestFactory.create(AppModule); + + // Register the ServiceErrorFilter globally + app.useGlobalFilters(new ServiceErrorFilter()); + + await app.listen(3000); +} +bootstrap(); \ No newline at end of file