diff --git a/packages/server/src/modules/App/App.module.ts b/packages/server/src/modules/App/App.module.ts index 1f16f54ec..fa61da1e1 100644 --- a/packages/server/src/modules/App/App.module.ts +++ b/packages/server/src/modules/App/App.module.ts @@ -85,6 +85,7 @@ import { ImportModule } from '../Import/Import.module'; import { CreditNotesApplyInvoiceModule } from '../CreditNotesApplyInvoice/CreditNotesApplyInvoice.module'; import { ResourceModule } from '../Resource/Resource.module'; import { ViewsModule } from '../Views/Views.module'; +import { CurrenciesModule } from '../Currencies/Currencies.module'; @Module({ imports: [ @@ -204,7 +205,8 @@ import { ViewsModule } from '../Views/Views.module'; ExportModule, ImportModule, ResourceModule, - ViewsModule + ViewsModule, + CurrenciesModule ], controllers: [AppController], providers: [ diff --git a/packages/server/src/modules/Currencies/Currencies.constants.ts b/packages/server/src/modules/Currencies/Currencies.constants.ts new file mode 100644 index 000000000..a2f433253 --- /dev/null +++ b/packages/server/src/modules/Currencies/Currencies.constants.ts @@ -0,0 +1,17 @@ +export const InitialCurrencies = [ + 'USD', + 'CAD', + 'EUR', + 'LYD', + 'GBP', + 'CNY', + 'AUD', + 'INR', +]; + +export const ERRORS = { + CURRENCY_NOT_FOUND: 'currency_not_found', + CURRENCY_CODE_EXISTS: 'currency_code_exists', + BASE_CURRENCY_INVALID: 'BASE_CURRENCY_INVALID', + CANNOT_DELETE_BASE_CURRENCY: 'CANNOT_DELETE_BASE_CURRENCY', +}; diff --git a/packages/server/src/modules/Currencies/Currencies.controller.ts b/packages/server/src/modules/Currencies/Currencies.controller.ts new file mode 100644 index 000000000..af304900b --- /dev/null +++ b/packages/server/src/modules/Currencies/Currencies.controller.ts @@ -0,0 +1,79 @@ +import { + Controller, + Post, + Put, + Delete, + Get, + Body, + Param, +} from '@nestjs/common'; +import { + ApiTags, + ApiOperation, + ApiBody, + ApiCreatedResponse, + ApiBadRequestResponse, + ApiParam, + ApiOkResponse, + ApiNotFoundResponse, +} from '@nestjs/swagger'; +import { CurrenciesApplication } from './CurrenciesApplication.service'; +import { CreateCurrencyDto } from './dtos/CreateCurrency.dto'; +import { EditCurrencyDto } from './dtos/EditCurrency.dto'; + +@ApiTags('currencies') +@Controller('/currencies') +export class CurrenciesController { + constructor(private readonly currenciesApp: CurrenciesApplication) {} + + @Post() + @ApiOperation({ summary: 'Create a new currency' }) + @ApiBody({ type: CreateCurrencyDto }) + @ApiCreatedResponse({ + description: 'The currency has been successfully created.', + }) + @ApiBadRequestResponse({ description: 'Invalid input data.' }) + create(@Body() dto: CreateCurrencyDto) { + return this.currenciesApp.createCurrency(dto); + } + + @Put(':id') + @ApiOperation({ summary: 'Edit an existing currency' }) + @ApiParam({ name: 'id', type: Number, description: 'Currency ID' }) + @ApiBody({ type: EditCurrencyDto }) + @ApiOkResponse({ description: 'The currency has been successfully updated.' }) + @ApiNotFoundResponse({ description: 'Currency not found.' }) + @ApiBadRequestResponse({ description: 'Invalid input data.' }) + edit(@Param('id') id: number, @Body() dto: EditCurrencyDto) { + return this.currenciesApp.editCurrency(Number(id), dto); + } + + @Delete(':code') + @ApiOperation({ summary: 'Delete a currency by code' }) + @ApiParam({ name: 'code', type: String, description: 'Currency code' }) + @ApiOkResponse({ description: 'The currency has been successfully deleted.' }) + @ApiNotFoundResponse({ description: 'Currency not found.' }) + delete(@Param('code') code: string) { + return this.currenciesApp.deleteCurrency(code); + } + + @Get() + @ApiOperation({ summary: 'Get all currencies' }) + @ApiOkResponse({ description: 'List of all currencies.' }) + findAll() { + return this.currenciesApp.getCurrencies(); + } + + @Get(':currencyCode') + @ApiOperation({ summary: 'Get a currency by code' }) + @ApiParam({ + name: 'currencyCode', + type: String, + description: 'Currency code', + }) + @ApiOkResponse({ description: 'The currency details.' }) + @ApiNotFoundResponse({ description: 'Currency not found.' }) + findOne(@Param('currencyCode') currencyCode: string) { + return this.currenciesApp.getCurrency(currencyCode); + } +} diff --git a/packages/server/src/modules/Currencies/Currencies.module.ts b/packages/server/src/modules/Currencies/Currencies.module.ts new file mode 100644 index 000000000..cc00e878f --- /dev/null +++ b/packages/server/src/modules/Currencies/Currencies.module.ts @@ -0,0 +1,32 @@ +import { Module } from '@nestjs/common'; +import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module'; +import { Currency } from './models/Currency.model'; +import { CreateCurrencyService } from './commands/CreateCurrency.service'; +import { EditCurrencyService } from './commands/EditCurrency.service'; +import { DeleteCurrencyService } from './commands/DeleteCurrency.service'; +import { SeedInitialCurrenciesOnSetupSubsriber } from './subscribers/SeedInitialCurrenciesOnSetup.subscriber'; +import { InitialCurrenciesSeedService } from './commands/InitialCurrenciesSeed.service'; +import { CurrenciesApplication } from './CurrenciesApplication.service'; +import { CurrenciesController } from './Currencies.controller'; +import { TenancyModule } from '../Tenancy/Tenancy.module'; +import { GetCurrenciesService } from './queries/GetCurrencies.service'; +import { GetCurrencyService } from './queries/GetCurrency.service'; + +const models = [RegisterTenancyModel(Currency)]; + +@Module({ + imports: [...models, TenancyModule], + exports: [...models], + providers: [ + CreateCurrencyService, + EditCurrencyService, + DeleteCurrencyService, + GetCurrenciesService, + GetCurrencyService, + CurrenciesApplication, + InitialCurrenciesSeedService, + SeedInitialCurrenciesOnSetupSubsriber + ], + controllers: [CurrenciesController] +}) +export class CurrenciesModule {} diff --git a/packages/server/src/modules/Currencies/CurrenciesApplication.service.ts b/packages/server/src/modules/Currencies/CurrenciesApplication.service.ts new file mode 100644 index 000000000..cb40cad48 --- /dev/null +++ b/packages/server/src/modules/Currencies/CurrenciesApplication.service.ts @@ -0,0 +1,54 @@ +import { Injectable } from '@nestjs/common'; +import { CreateCurrencyService } from './commands/CreateCurrency.service'; +import { EditCurrencyService } from './commands/EditCurrency.service'; +import { DeleteCurrencyService } from './commands/DeleteCurrency.service'; +import { GetCurrenciesService } from './queries/GetCurrencies.service'; +import { GetCurrencyService } from './queries/GetCurrency.service'; +import { CreateCurrencyDto } from './dtos/CreateCurrency.dto'; +import { EditCurrencyDto } from './dtos/EditCurrency.dto'; + +@Injectable() +export class CurrenciesApplication { + constructor( + private readonly createCurrencyService: CreateCurrencyService, + private readonly editCurrencyService: EditCurrencyService, + private readonly deleteCurrencyService: DeleteCurrencyService, + private readonly getCurrenciesService: GetCurrenciesService, + private readonly getCurrencyService: GetCurrencyService, + ) {} + + /** + * Creates a new currency. + */ + public createCurrency(currencyDTO: CreateCurrencyDto) { + return this.createCurrencyService.createCurrency(currencyDTO); + } + + /** + * Edits an existing currency. + */ + public editCurrency(currencyId: number, currencyDTO: EditCurrencyDto) { + return this.editCurrencyService.editCurrency(currencyId, currencyDTO); + } + + /** + * Deletes a currency by code. + */ + public deleteCurrency(currencyCode: string) { + return this.deleteCurrencyService.deleteCurrency(currencyCode); + } + + /** + * Gets a list of all currencies. + */ + public getCurrencies() { + return this.getCurrenciesService.getCurrencies(); + } + + /** + * Gets a single currency by id or code (to be implemented in GetCurrencyService). + */ + public getCurrency(currencyCode: string) { + return this.getCurrencyService.getCurrency(currencyCode); + } +} diff --git a/packages/server/src/modules/Currencies/Currency.transformer.ts b/packages/server/src/modules/Currencies/Currency.transformer.ts new file mode 100644 index 000000000..8ab985b87 --- /dev/null +++ b/packages/server/src/modules/Currencies/Currency.transformer.ts @@ -0,0 +1,19 @@ +import { Transformer } from "../Transformer/Transformer"; + +export class CurrencyTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return ['isBaseCurrency']; + }; + + /** + * Detarmines whether the currency is base currency. + * @returns {boolean} + */ + public isBaseCurrency(currency): boolean { + return this.context.organization.baseCurrency === currency.currencyCode; + } +} diff --git a/packages/server/src/modules/Currencies/commands/CreateCurrency.service.ts b/packages/server/src/modules/Currencies/commands/CreateCurrency.service.ts new file mode 100644 index 000000000..113f27850 --- /dev/null +++ b/packages/server/src/modules/Currencies/commands/CreateCurrency.service.ts @@ -0,0 +1,45 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { TenantModelProxy } from '../../System/models/TenantBaseModel'; +import { Currency } from '../models/Currency.model'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { ERRORS } from '../Currencies.constants'; +import { CreateCurrencyDto } from '../dtos/CreateCurrency.dto'; + +@Injectable() +export class CreateCurrencyService { + constructor( + @Inject(Currency.name) + private readonly currencyModel: TenantModelProxy, + ) {} + + async createCurrency(currencyDTO: CreateCurrencyDto) { + // Validate currency code uniquiness. + await this.validateCurrencyCodeUniquiness(currencyDTO.currencyCode); + await this.currencyModel() + .query() + .insert({ ...currencyDTO }); + } + + /** + * Retrieve currency by given currency code or throw not found error. + * @param {string} currencyCode + * @param {number} currencyId + */ + private async validateCurrencyCodeUniquiness( + currencyCode: string, + currencyId?: number, + ) { + const foundCurrency = await this.currencyModel() + .query() + .onBuild((query) => { + query.findOne('currency_code', currencyCode); + + if (currencyId) { + query.whereNot('id', currencyId); + } + }); + if (foundCurrency) { + throw new ServiceError(ERRORS.CURRENCY_CODE_EXISTS); + } + } +} diff --git a/packages/server/src/modules/Currencies/commands/DeleteCurrency.service.ts b/packages/server/src/modules/Currencies/commands/DeleteCurrency.service.ts new file mode 100644 index 000000000..a50fa867b --- /dev/null +++ b/packages/server/src/modules/Currencies/commands/DeleteCurrency.service.ts @@ -0,0 +1,46 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { TenantModelProxy } from '../../System/models/TenantBaseModel'; +import { Currency } from '../models/Currency.model'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; +import { ERRORS } from '../Currencies.constants'; + +@Injectable() +export class DeleteCurrencyService { + constructor( + @Inject(Currency.name) + private readonly currencyModel: TenantModelProxy, + private readonly tenancyContext: TenancyContext, + ) {} + + /** + * Delete the given currency code. + * @param {string} currencyCode + * @return {Promise} + */ + public async deleteCurrency(currencyCode: string): Promise { + const foundCurrency = await this.currencyModel().query() + .findOne('currency_code', currencyCode) + .throwIfNotFound(); + + // Validate currency code not equals base currency. + await this.validateCannotDeleteBaseCurrency(currencyCode); + + await this.currencyModel() + .query() + .where('currency_code', currencyCode) + .delete(); + } + + /** + * Validate cannot delete base currency. + * @param {string} currencyCode + */ + private async validateCannotDeleteBaseCurrency(currencyCode: string) { + const tenant = await this.tenancyContext.getTenant(true); + + if (tenant.metadata.baseCurrency === currencyCode) { + throw new ServiceError(ERRORS.CANNOT_DELETE_BASE_CURRENCY); + } + } +} diff --git a/packages/server/src/modules/Currencies/commands/EditCurrency.service.ts b/packages/server/src/modules/Currencies/commands/EditCurrency.service.ts new file mode 100644 index 000000000..2cb3fa96b --- /dev/null +++ b/packages/server/src/modules/Currencies/commands/EditCurrency.service.ts @@ -0,0 +1,35 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Currency } from '../models/Currency.model'; +import { TenantModelProxy } from '../../System/models/TenantBaseModel'; +import { EditCurrencyDto } from '../dtos/EditCurrency.dto'; + +@Injectable() +export class EditCurrencyService { + constructor( + @Inject(Currency.name) + private readonly currencyModel: TenantModelProxy, + ) {} + + /** + * Edit details of the given currency. + * @param {number} tenantId + * @param {number} currencyId + * @param {ICurrencyDTO} currencyDTO + */ + public async editCurrency( + currencyId: number, + currencyDTO: EditCurrencyDto, + ): Promise { + const foundCurrency = await this.currencyModel() + .query() + .findOne('id', currencyId) + .throwIfNotFound(); + + const currency = await this.currencyModel() + .query() + .patchAndFetchById(currencyId, { + ...currencyDTO, + }); + return currency; + } +} diff --git a/packages/server/src/modules/Currencies/commands/InitialCurrenciesSeed.service.ts b/packages/server/src/modules/Currencies/commands/InitialCurrenciesSeed.service.ts new file mode 100644 index 000000000..dac18638c --- /dev/null +++ b/packages/server/src/modules/Currencies/commands/InitialCurrenciesSeed.service.ts @@ -0,0 +1,47 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { uniq } from 'lodash'; +import Currencies from 'js-money/lib/currency'; +import { InitialCurrencies } from '../Currencies.constants'; +import { TenantModelProxy } from '../../System/models/TenantBaseModel'; +import { Currency } from '../models/Currency.model'; + +@Injectable() +export class InitialCurrenciesSeedService { + constructor( + @Inject(Currency.name) + private readonly currencyModel: TenantModelProxy, + ) {} + + /** + * Seeds the given base currency to the currencies list. + * @param {string} baseCurrency - Base currency code. + */ + public async seedCurrencyByCode(currencyCode: string): Promise { + const currencyMeta = Currencies[currencyCode]; + + const foundBaseCurrency = await this.currencyModel() + .query() + .findOne('currency_code', currencyCode); + if (!foundBaseCurrency) { + await this.currencyModel().query().insert({ + currencyCode: currencyMeta.code, + currencyName: currencyMeta.name, + currencySign: currencyMeta.symbol, + }); + } + } + + /** + * Seeds initial currencies to the organization. + * @param {string} baseCurrency - Base currency code. + */ + public async seedInitialCurrencies(baseCurrency: string): Promise { + const initialCurrencies = uniq([...InitialCurrencies, baseCurrency]); + + // Seed currency opers. + const seedCurrencyOpers = initialCurrencies.map((currencyCode) => { + return this.seedCurrencyByCode(currencyCode); + }); + await Promise.all(seedCurrencyOpers); + } +} diff --git a/packages/server/src/modules/Currencies/dtos/CreateCurrency.dto.ts b/packages/server/src/modules/Currencies/dtos/CreateCurrency.dto.ts new file mode 100644 index 000000000..7d85e1ab2 --- /dev/null +++ b/packages/server/src/modules/Currencies/dtos/CreateCurrency.dto.ts @@ -0,0 +1,12 @@ +import { IsString } from 'class-validator'; + +export class CreateCurrencyDto { + @IsString() + currencyName: string; + + @IsString() + currencyCode: string; + + @IsString() + currencySign: string; +} diff --git a/packages/server/src/modules/Currencies/dtos/EditCurrency.dto.ts b/packages/server/src/modules/Currencies/dtos/EditCurrency.dto.ts new file mode 100644 index 000000000..b49d1b58e --- /dev/null +++ b/packages/server/src/modules/Currencies/dtos/EditCurrency.dto.ts @@ -0,0 +1,9 @@ +import { IsString } from 'class-validator'; + +export class EditCurrencyDto { + @IsString() + currencyName: string; + + @IsString() + currencySign: string; +} diff --git a/packages/server/src/modules/Currencies/models/Currency.model.ts b/packages/server/src/modules/Currencies/models/Currency.model.ts new file mode 100644 index 000000000..29abf94f2 --- /dev/null +++ b/packages/server/src/modules/Currencies/models/Currency.model.ts @@ -0,0 +1,25 @@ +import { TenantModel } from "@/modules/System/models/TenantModel"; + +export class Currency extends TenantModel { + public readonly currencySign: string; + public readonly currencyName: string; + public readonly currencyCode: string; + + /** + * Table name + */ + static get tableName() { + return 'currencies'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + static get resourceable() { + return true; + } +} \ No newline at end of file diff --git a/packages/server/src/modules/Currencies/queries/GetCurrencies.service.ts b/packages/server/src/modules/Currencies/queries/GetCurrencies.service.ts new file mode 100644 index 000000000..19f4840ca --- /dev/null +++ b/packages/server/src/modules/Currencies/queries/GetCurrencies.service.ts @@ -0,0 +1,29 @@ +import { Inject, Injectable } from "@nestjs/common"; +import { Currency } from "../models/Currency.model"; +import { TenantModelProxy } from "../../System/models/TenantBaseModel"; +import { TransformerInjectable } from "../../Transformer/TransformerInjectable.service"; +import { CurrencyTransformer } from "../Currency.transformer"; + +@Injectable() +export class GetCurrenciesService { + constructor( + @Inject(Currency.name) + private readonly currencyModel: TenantModelProxy, + private readonly transformerInjectable: TransformerInjectable, + ) { + + } + /** + * Retrieves currencies list. + * @return {Promise} + */ + public async getCurrencies(): Promise { + const currencies = await this.currencyModel().query().onBuild((query) => { + query.orderBy('createdAt', 'ASC'); + }); + return this.transformerInjectable.transform( + currencies, + new CurrencyTransformer() + ); + } +} \ No newline at end of file diff --git a/packages/server/src/modules/Currencies/queries/GetCurrency.service.ts b/packages/server/src/modules/Currencies/queries/GetCurrency.service.ts new file mode 100644 index 000000000..731c5450d --- /dev/null +++ b/packages/server/src/modules/Currencies/queries/GetCurrency.service.ts @@ -0,0 +1,26 @@ +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { Currency } from '../models/Currency.model'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { CurrencyTransformer } from '../Currency.transformer'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class GetCurrencyService { + constructor( + @Inject(Currency.name) + private readonly currencyModel: TenantModelProxy, + private readonly transformInjectable: TransformerInjectable, + ) {} + + getCurrency(currencyCode: string) { + const currency = this.currencyModel() + .query() + .findOne('currencyCode', currencyCode) + .throwIfNotFound(); + + return this.transformInjectable.transform( + currency, + new CurrencyTransformer(), + ); + } +} diff --git a/packages/server/src/modules/Currencies/subscribers/SeedInitialCurrenciesOnSetup.subscriber.ts b/packages/server/src/modules/Currencies/subscribers/SeedInitialCurrenciesOnSetup.subscriber.ts new file mode 100644 index 000000000..0ec833b09 --- /dev/null +++ b/packages/server/src/modules/Currencies/subscribers/SeedInitialCurrenciesOnSetup.subscriber.ts @@ -0,0 +1,26 @@ +import { OnEvent } from '@nestjs/event-emitter'; +import { Injectable } from '@nestjs/common'; +import { InitialCurrenciesSeedService } from '../commands/InitialCurrenciesSeed.service'; +import { IOrganizationBuildEventPayload } from '@/modules/Organization/Organization.types'; +import { events } from '@/common/events/events'; + +@Injectable() +export class SeedInitialCurrenciesOnSetupSubsriber { + constructor( + private readonly seedInitialCurrencies: InitialCurrenciesSeedService, + ) {} + + /** + * Seed initial currencies once organization build. + * @param {IOrganizationBuildEventPayload} + */ + @OnEvent(events.organization.build) + async seedInitialCurrenciesOnBuild({ + systemUser, + buildDTO, + }: IOrganizationBuildEventPayload) { + await this.seedInitialCurrencies.seedInitialCurrencies( + buildDTO.baseCurrency, + ); + } +}