From ef22b9ddaf61078f9a0e4655358099dada81f33f Mon Sep 17 00:00:00 2001 From: Ahmed Bouhuolia Date: Mon, 24 Mar 2025 23:38:43 +0200 Subject: [PATCH] refactor: subscriptions to nestjs --- .gitignore | 3 +- packages/server-nest/package.json | 1 + .../Accounts/AccountsExportable.service.ts | 55 ++- .../server-nest/src/modules/App/App.module.ts | 9 +- .../modules/Dashboard/Dashboard.controller.ts | 19 + .../src/modules/Dashboard/Dashboard.module.ts | 11 + .../modules/Dashboard/Dashboard.service.ts | 76 ++++ .../src/modules/Export/Export.controller.ts | 46 +++ .../src/modules/Export/Export.module.ts | 12 + .../src/modules/Export/ExportApplication.ts | 4 +- .../src/modules/Export/ExportPdf.ts | 12 +- .../src/modules/Export/ExportResources.ts | 5 +- .../src/modules/Export/ExportService.ts | 19 +- .../modules/Export/dtos/ExportQuery.dto.ts | 11 + .../GeneralLedger/GeneralLedgerApplication.ts | 3 +- .../ItemCategory.application.ts | 5 +- .../ItemCategories/ItemCategory.controller.ts | 9 +- .../commands/CreateItemCategory.service.ts | 19 +- .../commands/EditItemCategory.service.ts | 16 +- .../ItemCategories/dtos/ItemCategory.dto.ts | 34 +- .../Organization/Organization.controller.ts | 0 .../Organization/Organization.module.ts | 0 .../OrganizationBaseCurrencyLocking.ts | 84 +++++ .../Organization/OrganizationService.ts | 338 ++++++++++++++++++ .../Organization/OrganizationUpgrade.ts | 102 ++++++ .../Organization/Organization/constants.ts | 43 +++ .../commands/BuildOrganization.service.ts | 0 .../commands/UpdateOrganization.service.ts | 0 .../queries/GetCurrentOrganization.service.ts | 0 ...GetOrganizationBaseCurrencyLock.service.ts | 0 .../CreateInvoiceCheckoutSession.ts | 104 ++++++ .../GetInvoicePaymentLinkMetadata.ts | 63 ++++ .../PaymentLinks/GetPaymentLinkInvoicePdf.ts | 34 ++ .../PaymentLinks/PaymentLinks.controller.ts | 44 +++ .../PaymentLinks/PaymentLinks.module.ts | 21 ++ .../PaymentLinks/PaymentLinksApplication.ts | 51 +++ .../PaymentLinks/models/PaymentLink.ts | 4 +- .../models/PaymentIntegration.model.ts | 9 +- .../TransactionPaymentServiceEntry.model.ts | 9 + .../src/modules/Roles/AbilitySchema.ts | 332 +++++++++++++++++ .../src/modules/Roles/Authorization.guard.ts | 56 +++ .../src/modules/Roles/Roles.application.ts | 62 ++++ .../src/modules/Roles/Roles.controller.ts | 115 ++++++ .../src/modules/Roles/Roles.module.ts | 31 ++ .../src/modules/Roles/Roles.types.ts | 83 +++++ .../src/modules/Roles/TenantAbilities.ts | 55 +++ .../Roles/commands/CreateRole.service.ts | 51 +++ .../Roles/commands/DeleteRole.service.ts | 61 ++++ .../Roles/commands/EditRole.service.ts | 57 +++ .../src/modules/Roles/constants.ts | 6 + .../src/modules/Roles/dtos/Role.dto.ts | 46 +++ .../src/modules/Roles/models/Role.model.ts | 39 ++ .../Roles/models/RolePermission.model.ts | 36 ++ .../modules/Roles/queries/GetRole.service.ts | 44 +++ .../modules/Roles/queries/GetRoles.service.ts | 27 ++ .../Roles/queries/RolePermissionsSchema.ts | 12 + .../modules/Roles/queries/RoleTransformer.ts | 31 ++ .../server-nest/src/modules/Roles/utils.ts | 42 +++ .../SaleInvoices/models/SaleInvoice.ts | 4 +- .../subscribers/StripeWebhooksSubscriber.ts | 8 +- .../Subscription/Subscription.module.ts | 34 ++ .../Subscription/SubscriptionApplication.ts | 148 ++++++++ .../Subscription/Subscriptions.controller.ts | 169 +++++++++ .../SubscriptionsLemonWebhook.controller.ts | 22 ++ .../CancelLemonSubscription.service.ts | 52 +++ .../ChangeLemonSubscription.service.ts | 54 +++ .../MarkSubscriptionCanceled.service.ts | 46 +++ .../MarkSubscriptionChanged.service.ts | 48 +++ .../MarkSubscriptionPaymentFailed.service.ts | 43 +++ ...arkSubscriptionPaymentSuccessed.service.ts | 42 +++ .../MarkSubscriptionResumed.sevice.ts | 47 +++ .../commands/NewSubscription.service.ts | 68 ++++ .../ResumeLemonSubscription.service.ts | 51 +++ .../interceptors/Subscription.guard.ts | 1 - .../src/modules/Subscription/models/Plan.ts | 87 +++++ .../Subscription/models/PlanSubscription.ts | 1 + .../GetLemonSqueezyCheckout.service.ts | 49 +++ .../queries/GetSubscriptions.service.ts | 57 +++ .../queries/GetSubscriptionsTransformer.ts | 168 +++++++++ .../SubscribeFreeOnSignupCommunity.ts | 29 ++ ...ggerInvalidateCacheOnSubscriptionChange.ts | 16 + .../src/modules/Subscription/types.ts | 28 ++ .../src/modules/Subscription/utils.ts | 100 ++++++ .../webhooks/LemonSqueezyWebhooks.ts | 135 +++++++ .../Tenancy/TenancyModels/Tenancy.module.ts | 2 + .../TenancyModels/models/TenantUser.model.ts | 68 ++++ pnpm-lock.yaml | 3 + 87 files changed, 3949 insertions(+), 92 deletions(-) create mode 100644 packages/server-nest/src/modules/Dashboard/Dashboard.controller.ts create mode 100644 packages/server-nest/src/modules/Dashboard/Dashboard.module.ts create mode 100644 packages/server-nest/src/modules/Dashboard/Dashboard.service.ts create mode 100644 packages/server-nest/src/modules/Export/Export.controller.ts create mode 100644 packages/server-nest/src/modules/Export/Export.module.ts create mode 100644 packages/server-nest/src/modules/Export/dtos/ExportQuery.dto.ts create mode 100644 packages/server-nest/src/modules/Organization/Organization.controller.ts create mode 100644 packages/server-nest/src/modules/Organization/Organization.module.ts create mode 100644 packages/server-nest/src/modules/Organization/Organization/OrganizationBaseCurrencyLocking.ts create mode 100644 packages/server-nest/src/modules/Organization/Organization/OrganizationService.ts create mode 100644 packages/server-nest/src/modules/Organization/Organization/OrganizationUpgrade.ts create mode 100644 packages/server-nest/src/modules/Organization/Organization/constants.ts create mode 100644 packages/server-nest/src/modules/Organization/commands/BuildOrganization.service.ts create mode 100644 packages/server-nest/src/modules/Organization/commands/UpdateOrganization.service.ts create mode 100644 packages/server-nest/src/modules/Organization/queries/GetCurrentOrganization.service.ts create mode 100644 packages/server-nest/src/modules/Organization/queries/GetOrganizationBaseCurrencyLock.service.ts create mode 100644 packages/server-nest/src/modules/PaymentLinks/CreateInvoiceCheckoutSession.ts create mode 100644 packages/server-nest/src/modules/PaymentLinks/GetInvoicePaymentLinkMetadata.ts create mode 100644 packages/server-nest/src/modules/PaymentLinks/GetPaymentLinkInvoicePdf.ts create mode 100644 packages/server-nest/src/modules/PaymentLinks/PaymentLinks.controller.ts create mode 100644 packages/server-nest/src/modules/PaymentLinks/PaymentLinks.module.ts create mode 100644 packages/server-nest/src/modules/PaymentLinks/PaymentLinksApplication.ts create mode 100644 packages/server-nest/src/modules/Roles/AbilitySchema.ts create mode 100644 packages/server-nest/src/modules/Roles/Authorization.guard.ts create mode 100644 packages/server-nest/src/modules/Roles/Roles.application.ts create mode 100644 packages/server-nest/src/modules/Roles/Roles.controller.ts create mode 100644 packages/server-nest/src/modules/Roles/Roles.module.ts create mode 100644 packages/server-nest/src/modules/Roles/Roles.types.ts create mode 100644 packages/server-nest/src/modules/Roles/TenantAbilities.ts create mode 100644 packages/server-nest/src/modules/Roles/commands/CreateRole.service.ts create mode 100644 packages/server-nest/src/modules/Roles/commands/DeleteRole.service.ts create mode 100644 packages/server-nest/src/modules/Roles/commands/EditRole.service.ts create mode 100644 packages/server-nest/src/modules/Roles/constants.ts create mode 100644 packages/server-nest/src/modules/Roles/dtos/Role.dto.ts create mode 100644 packages/server-nest/src/modules/Roles/models/Role.model.ts create mode 100644 packages/server-nest/src/modules/Roles/models/RolePermission.model.ts create mode 100644 packages/server-nest/src/modules/Roles/queries/GetRole.service.ts create mode 100644 packages/server-nest/src/modules/Roles/queries/GetRoles.service.ts create mode 100644 packages/server-nest/src/modules/Roles/queries/RolePermissionsSchema.ts create mode 100644 packages/server-nest/src/modules/Roles/queries/RoleTransformer.ts create mode 100644 packages/server-nest/src/modules/Roles/utils.ts create mode 100644 packages/server-nest/src/modules/Subscription/Subscription.module.ts create mode 100644 packages/server-nest/src/modules/Subscription/SubscriptionApplication.ts create mode 100644 packages/server-nest/src/modules/Subscription/Subscriptions.controller.ts create mode 100644 packages/server-nest/src/modules/Subscription/SubscriptionsLemonWebhook.controller.ts create mode 100644 packages/server-nest/src/modules/Subscription/commands/CancelLemonSubscription.service.ts create mode 100644 packages/server-nest/src/modules/Subscription/commands/ChangeLemonSubscription.service.ts create mode 100644 packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionCanceled.service.ts create mode 100644 packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionChanged.service.ts create mode 100644 packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionPaymentFailed.service.ts create mode 100644 packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionPaymentSuccessed.service.ts create mode 100644 packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionResumed.sevice.ts create mode 100644 packages/server-nest/src/modules/Subscription/commands/NewSubscription.service.ts create mode 100644 packages/server-nest/src/modules/Subscription/commands/ResumeLemonSubscription.service.ts create mode 100644 packages/server-nest/src/modules/Subscription/models/Plan.ts create mode 100644 packages/server-nest/src/modules/Subscription/queries/GetLemonSqueezyCheckout.service.ts create mode 100644 packages/server-nest/src/modules/Subscription/queries/GetSubscriptions.service.ts create mode 100644 packages/server-nest/src/modules/Subscription/queries/GetSubscriptionsTransformer.ts create mode 100644 packages/server-nest/src/modules/Subscription/subscribers/SubscribeFreeOnSignupCommunity.ts create mode 100644 packages/server-nest/src/modules/Subscription/subscribers/TriggerInvalidateCacheOnSubscriptionChange.ts create mode 100644 packages/server-nest/src/modules/Subscription/types.ts create mode 100644 packages/server-nest/src/modules/Subscription/utils.ts create mode 100644 packages/server-nest/src/modules/Subscription/webhooks/LemonSqueezyWebhooks.ts create mode 100644 packages/server-nest/src/modules/Tenancy/TenancyModels/models/TenantUser.model.ts diff --git a/.gitignore b/.gitignore index 83973c22d..53db44a24 100644 --- a/.gitignore +++ b/.gitignore @@ -6,4 +6,5 @@ node_modules/ # Production env file .env -test-results/ \ No newline at end of file +test-results/ +.qodo diff --git a/packages/server-nest/package.json b/packages/server-nest/package.json index 5f6c47683..54bfe4ea8 100644 --- a/packages/server-nest/package.json +++ b/packages/server-nest/package.json @@ -41,6 +41,7 @@ "@types/nodemailer": "^6.4.17", "@types/passport-local": "^1.0.38", "@types/ramda": "^0.30.2", + "@lemonsqueezy/lemonsqueezy.js": "^2.2.0", "accounting": "^0.4.1", "async": "^3.2.0", "async-mutex": "^0.5.0", diff --git a/packages/server-nest/src/modules/Accounts/AccountsExportable.service.ts b/packages/server-nest/src/modules/Accounts/AccountsExportable.service.ts index 64c30b061..4c010806e 100644 --- a/packages/server-nest/src/modules/Accounts/AccountsExportable.service.ts +++ b/packages/server-nest/src/modules/Accounts/AccountsExportable.service.ts @@ -1,32 +1,29 @@ -// import { Inject, Service } from 'typedi'; -// import { AccountsApplication } from './AccountsApplication.service'; -// import { Exportable } from '../Export/Exportable'; -// import { IAccountsFilter, IAccountsStructureType } from '@/interfaces'; -// import { EXPORT_SIZE_LIMIT } from '../Export/constants'; +import { AccountsApplication } from './AccountsApplication.service'; +import { Exportable } from '../Export/Exportable'; +import { EXPORT_SIZE_LIMIT } from '../Export/constants'; +import { IAccountsFilter, IAccountsStructureType } from './Accounts.types'; -// @Service() -// export class AccountsExportable extends Exportable { -// @Inject() -// private accountsApplication: AccountsApplication; +export class AccountsExportable extends Exportable { + constructor(private readonly accountsApplication: AccountsApplication) { + super(); + } -// /** -// * Retrieves the accounts data to exportable sheet. -// * @param {number} tenantId -// * @returns -// */ -// public exportable(tenantId: number, query: IAccountsFilter) { -// const parsedQuery = { -// sortOrder: 'desc', -// columnSortBy: 'created_at', -// inactiveMode: false, -// ...query, -// structure: IAccountsStructureType.Flat, -// pageSize: EXPORT_SIZE_LIMIT, -// page: 1, -// } as IAccountsFilter; + /** + * Retrieves the accounts data to exportable sheet. + */ + public exportable(query: IAccountsFilter) { + const parsedQuery = { + sortOrder: 'desc', + columnSortBy: 'created_at', + inactiveMode: false, + ...query, + structure: IAccountsStructureType.Flat, + pageSize: EXPORT_SIZE_LIMIT, + page: 1, + } as IAccountsFilter; -// return this.accountsApplication -// .getAccounts(tenantId, parsedQuery) -// .then((output) => output.accounts); -// } -// } + return this.accountsApplication + .getAccounts(parsedQuery) + .then((output) => output.accounts); + } +} diff --git a/packages/server-nest/src/modules/App/App.module.ts b/packages/server-nest/src/modules/App/App.module.ts index 0b601f572..0275a339b 100644 --- a/packages/server-nest/src/modules/App/App.module.ts +++ b/packages/server-nest/src/modules/App/App.module.ts @@ -72,7 +72,10 @@ import { StripePaymentModule } from '../StripePayment/StripePayment.module'; import { FeaturesModule } from '../Features/Features.module'; import { InventoryCostModule } from '../InventoryCost/InventoryCost.module'; import { WarehousesTransfersModule } from '../WarehousesTransfers/WarehouseTransfers.module'; - +import { DashboardModule } from '../Dashboard/Dashboard.module'; +import { PaymentLinksModule } from '../PaymentLinks/PaymentLinks.module'; +import { RolesModule } from '../Roles/Roles.module'; +import { SubscriptionModule } from '../Subscription/Subscription.module'; @Module({ imports: [ @@ -180,6 +183,10 @@ import { WarehousesTransfersModule } from '../WarehousesTransfers/WarehouseTrans EventTrackerModule, FinancialStatementsModule, StripePaymentModule, + DashboardModule, + PaymentLinksModule, + RolesModule, + SubscriptionModule ], controllers: [AppController], providers: [ diff --git a/packages/server-nest/src/modules/Dashboard/Dashboard.controller.ts b/packages/server-nest/src/modules/Dashboard/Dashboard.controller.ts new file mode 100644 index 000000000..ea715996e --- /dev/null +++ b/packages/server-nest/src/modules/Dashboard/Dashboard.controller.ts @@ -0,0 +1,19 @@ +import { DashboardService } from './Dashboard.service'; +import { Controller, Get } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiResponse } from '@nestjs/swagger'; + +@ApiTags('dashboard') +@Controller('dashboard') +export class DashboardController { + constructor(private readonly dashboardService: DashboardService) {} + + @ApiOperation({ summary: 'Get dashboard boot metadata' }) + @ApiResponse({ + status: 200, + description: 'Returns dashboard boot metadata including abilities, features, and cloud status', + }) + @Get('boot') + getBootMeta() { + return this.dashboardService.getBootMeta(); + } +} diff --git a/packages/server-nest/src/modules/Dashboard/Dashboard.module.ts b/packages/server-nest/src/modules/Dashboard/Dashboard.module.ts new file mode 100644 index 000000000..b5f646797 --- /dev/null +++ b/packages/server-nest/src/modules/Dashboard/Dashboard.module.ts @@ -0,0 +1,11 @@ +import { Module } from '@nestjs/common'; +import { DashboardService } from './Dashboard.service'; +import { FeaturesModule } from '../Features/Features.module'; +import { DashboardController } from './Dashboard.controller'; + +@Module({ + imports: [FeaturesModule], + providers: [DashboardService], + controllers: [DashboardController], +}) +export class DashboardModule {} diff --git a/packages/server-nest/src/modules/Dashboard/Dashboard.service.ts b/packages/server-nest/src/modules/Dashboard/Dashboard.service.ts new file mode 100644 index 000000000..8f6987a10 --- /dev/null +++ b/packages/server-nest/src/modules/Dashboard/Dashboard.service.ts @@ -0,0 +1,76 @@ +import { TenantModelProxy } from '../System/models/TenantBaseModel'; +import { FeaturesManager } from '../Features/FeaturesManager'; +import { ConfigService } from '@nestjs/config'; +import { TenancyContext } from '../Tenancy/TenancyContext.service'; +import { IFeatureAllItem } from '@/common/types/Features'; +import { Inject } from '@nestjs/common'; +import { TenantUser } from '../Tenancy/TenancyModels/models/TenantUser.model'; + +interface IRoleAbility { + subject: string; + ability: string; +} + +interface IDashboardBootMeta { + abilities: IRoleAbility[]; + features: IFeatureAllItem[]; + isBigcapitalCloud: boolean; +} + +export class DashboardService { + constructor( + private readonly featuresManager: FeaturesManager, + private readonly configService: ConfigService, + private readonly tenancyContext: TenancyContext, + + @Inject(TenantUser.name) + private readonly tenantUserModel: TenantModelProxy, + ) {} + + /** + * Retrieve dashboard meta. + * @param {number} tenantId + * @param {number} authorizedUser + */ + public getBootMeta = async (): Promise => { + // Retrieves all orgnaization abilities. + const abilities = await this.getBootAbilities(); + + // Retrieves all organization features. + const features = await this.featuresManager.all(); + + return { + abilities, + features, + isBigcapitalCloud: this.configService.get('hostedOnBigcapitalCloud'), + }; + }; + + /** + * Transformes role permissions to abilities. + */ + transformRoleAbility = (permissions) => { + return permissions + .filter((permission) => permission.value) + .map((permission) => ({ + subject: permission.subject, + action: permission.ability, + })); + }; + + /** + * Retrieve the boot abilities. + * @returns + */ + private getBootAbilities = async (): Promise => { + const authorizedUser = await this.tenancyContext.getSystemUser(); + + const tenantUser = await this.tenantUserModel().query() + .findOne('systemUserId', authorizedUser.id) + .withGraphFetched('role.permissions'); + + return tenantUser.role.slug === 'admin' + ? [{ subject: 'all', action: 'manage' }] + : this.transformRoleAbility(tenantUser.role.permissions); + }; +} diff --git a/packages/server-nest/src/modules/Export/Export.controller.ts b/packages/server-nest/src/modules/Export/Export.controller.ts new file mode 100644 index 000000000..e13559948 --- /dev/null +++ b/packages/server-nest/src/modules/Export/Export.controller.ts @@ -0,0 +1,46 @@ +import { Response } from 'express'; +import { convertAcceptFormatToFormat } from './_utils'; +import { Controller, Headers, Query, Res } from '@nestjs/common'; +import { ExportQuery } from './dtos/ExportQuery.dto'; +import { ExportResourceService } from './ExportService'; +import { AcceptType } from '@/constants/accept-type'; + +@Controller('/export') +export class ExportController { + constructor(private readonly exportResourceApp: ExportResourceService) {} + + async export( + @Query() query: ExportQuery, + @Res() res: Response, + @Headers('accept') acceptHeader: string, + ) { + const applicationFormat = convertAcceptFormatToFormat(acceptType); + + const data = await this.exportResourceApp.export( + query.resource, + applicationFormat, + ); + // Retrieves the csv format. + if (acceptHeader.includes(AcceptType.ApplicationCsv)) { + res.setHeader('Content-Disposition', 'attachment; filename=output.csv'); + res.setHeader('Content-Type', 'text/csv'); + + return res.send(data); + // Retrieves the xlsx format. + } else if (acceptHeader.includes(AcceptType.ApplicationXlsx)) { + res.setHeader('Content-Disposition', 'attachment; filename=output.xlsx'); + res.setHeader( + 'Content-Type', + 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', + ); + return res.send(data); + // Retrieve the pdf format. + } else if (acceptHeader.includes(AcceptType.ApplicationPdf)) { + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': data.length, + }); + res.send(data); + } + } +} diff --git a/packages/server-nest/src/modules/Export/Export.module.ts b/packages/server-nest/src/modules/Export/Export.module.ts new file mode 100644 index 000000000..ca47b42cc --- /dev/null +++ b/packages/server-nest/src/modules/Export/Export.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { ExportController } from './Export.controller'; +import { ExportResourceService } from './ExportService'; +import { ExportPdf } from './ExportPdf'; +import { ExportAls } from './ExportAls'; +import { ExportApplication } from './ExportApplication'; + +@Module({ + providers: [ExportResourceService, ExportPdf, ExportAls, ExportApplication], + controllers: [ExportController], +}) +export class ExportModule {} diff --git a/packages/server-nest/src/modules/Export/ExportApplication.ts b/packages/server-nest/src/modules/Export/ExportApplication.ts index b218c722e..0743c8fd0 100644 --- a/packages/server-nest/src/modules/Export/ExportApplication.ts +++ b/packages/server-nest/src/modules/Export/ExportApplication.ts @@ -16,7 +16,7 @@ export class ExportApplication { * @param {string} reosurce * @param {ExportFormat} format */ - public export(tenantId: number, resource: string, format: ExportFormat) { - return this.exportResource.export(tenantId, resource, format); + public export(resource: string, format: ExportFormat) { + return this.exportResource.export(resource, format); } } diff --git a/packages/server-nest/src/modules/Export/ExportPdf.ts b/packages/server-nest/src/modules/Export/ExportPdf.ts index 74a643c32..88fc2923a 100644 --- a/packages/server-nest/src/modules/Export/ExportPdf.ts +++ b/packages/server-nest/src/modules/Export/ExportPdf.ts @@ -1,8 +1,7 @@ -// @ts-nocheck -// import { ChromiumlyTenancy } from '../ChromiumlyTenancy/ChromiumlyTenancy'; -// import { TemplateInjectable } from '../TemplateInjectable/TemplateInjectable'; -import { mapPdfRows } from './utils'; import { Injectable } from '@nestjs/common'; +import { ChromiumlyTenancy } from '../ChromiumlyTenancy/ChromiumlyTenancy.service'; +import { TemplateInjectable } from '../TemplateInjectable/TemplateInjectable.service'; +import { mapPdfRows } from './utils'; @Injectable() export class ExportPdf { @@ -13,7 +12,6 @@ export class ExportPdf { /** * Generates the pdf table sheet for the given data and columns. - * @param {number} tenantId * @param {} columns * @param {Record} data * @param {string} sheetTitle @@ -21,7 +19,6 @@ export class ExportPdf { * @returns */ public async pdf( - tenantId: number, columns: { accessor: string }, data: Record, sheetTitle: string = '', @@ -30,7 +27,6 @@ export class ExportPdf { const rows = mapPdfRows(columns, data); const htmlContent = await this.templateInjectable.render( - tenantId, 'modules/export-resource-table', { table: { rows, columns }, @@ -39,7 +35,7 @@ export class ExportPdf { } ); // Convert the HTML content to PDF - return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent, { + return this.chromiumlyTenancy.convertHtmlContent(htmlContent, { margins: { top: 0.2, bottom: 0.2, left: 0.2, right: 0.2 }, landscape: true, }); diff --git a/packages/server-nest/src/modules/Export/ExportResources.ts b/packages/server-nest/src/modules/Export/ExportResources.ts index c590a8682..7033e34e6 100644 --- a/packages/server-nest/src/modules/Export/ExportResources.ts +++ b/packages/server-nest/src/modules/Export/ExportResources.ts @@ -1,4 +1,3 @@ -// @ts-nocheck // import Container, { Service } from 'typedi'; // import { AccountsExportable } from '../Accounts/AccountsExportable'; // import { ExportableRegistry } from './ExportRegistery'; @@ -20,10 +19,10 @@ import { Injectable } from "@nestjs/common"; import { ExportableRegistry } from "./ExportRegistery"; +import { AccountsExportable } from "../Accounts/AccountsExportable.service"; @Injectable() export class ExportableResources { - constructor( private readonly exportRegistry: ExportableRegistry, ) { @@ -34,7 +33,7 @@ export class ExportableResources { * Importable instances. */ private importables = [ - // { resource: 'Account', exportable: AccountsExportable }, + { resource: 'Account', exportable: AccountsExportable }, // { resource: 'Item', exportable: ItemsExportable }, // { resource: 'ItemCategory', exportable: ItemCategoriesExportable }, // { resource: 'Customer', exportable: CustomersExportable }, diff --git a/packages/server-nest/src/modules/Export/ExportService.ts b/packages/server-nest/src/modules/Export/ExportService.ts index a6bf3ef97..3a7a5b461 100644 --- a/packages/server-nest/src/modules/Export/ExportService.ts +++ b/packages/server-nest/src/modules/Export/ExportService.ts @@ -23,29 +23,25 @@ export class ExportResourceService { /** * - * @param {number} tenantId * @param {string} resourceName * @param {ExportFormat} format * @returns */ public async export( - tenantId: number, resourceName: string, format: ExportFormat = ExportFormat.Csv ) { return this.exportAls.run(() => - this.exportAlsRun(tenantId, resourceName, format) + this.exportAlsRun(resourceName, format) ); } /** * Exports the given resource data through csv, xlsx or pdf. - * @param {number} tenantId - Tenant id. * @param {string} resourceName - Resource name. * @param {ExportFormat} format - File format. */ public async exportAlsRun( - tenantId: number, resourceName: string, format: ExportFormat = ExportFormat.Csv ) { @@ -85,8 +81,8 @@ export class ExportResourceService { * @param {string} resource - The name of the resource. * @returns The metadata of the resource. */ - private getResourceMeta(tenantId: number, resource: string) { - return this.resourceService.getResourceMeta(tenantId, resource); + private getResourceMeta(resource: string) { + return this.resourceService.getResourceMeta(resource); } /** @@ -104,18 +100,15 @@ export class ExportResourceService { * Transforms the exported data based on the resource metadata. * If the resource metadata specifies a flattening attribute (`exportFlattenOn`), * the data will be flattened based on this attribute using the `flatDataCollections` utility function. - * - * @param {number} tenantId - The tenant identifier. * @param {string} resource - The name of the resource. * @param {Array>} data - The original data to be transformed. * @returns {Array>} - The transformed data. */ private transformExportedData( - tenantId: number, resource: string, data: Array> ): Array> { - const resourceMeta = this.getResourceMeta(tenantId, resource); + const resourceMeta = this.getResourceMeta(resource); return R.when>, Array>>( R.always(Boolean(resourceMeta.exportFlattenOn)), @@ -129,11 +122,11 @@ export class ExportResourceService { * @param {string} resource - The name of the resource. * @returns A promise that resolves to the exportable data. */ - private async getExportableData(tenantId: number, resource: string) { + private async getExportableData(resource: string) { const exportable = this.exportableResources.registry.getExportable(resource); - return exportable.exportable(tenantId, {}); + return exportable.exportable({}); } /** diff --git a/packages/server-nest/src/modules/Export/dtos/ExportQuery.dto.ts b/packages/server-nest/src/modules/Export/dtos/ExportQuery.dto.ts new file mode 100644 index 000000000..04d1eae67 --- /dev/null +++ b/packages/server-nest/src/modules/Export/dtos/ExportQuery.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsString } from 'class-validator'; + +export class ExportQuery { + @IsString() + @IsNotEmpty() + resource: string; + + @IsString() + @IsNotEmpty() + format: string; +} diff --git a/packages/server-nest/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedgerApplication.ts b/packages/server-nest/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedgerApplication.ts index a056c43c2..27b61129c 100644 --- a/packages/server-nest/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedgerApplication.ts +++ b/packages/server-nest/src/modules/FinancialStatements/modules/GeneralLedger/GeneralLedgerApplication.ts @@ -39,7 +39,7 @@ export class GeneralLedgerApplication { /** * Retrieves the G/L sheet in xlsx format. * @param {IGeneralLedgerSheetQuery} query - * @returns {} + * @returns {Promise} */ public xlsx( query: IGeneralLedgerSheetQuery, @@ -50,6 +50,7 @@ export class GeneralLedgerApplication { /** * Retrieves the G/L sheet in csv format. * @param {IGeneralLedgerSheetQuery} query - + * @returns {Promise} */ public csv( query: IGeneralLedgerSheetQuery, diff --git a/packages/server-nest/src/modules/ItemCategories/ItemCategory.application.ts b/packages/server-nest/src/modules/ItemCategories/ItemCategory.application.ts index c36472f75..7bb7c6aec 100644 --- a/packages/server-nest/src/modules/ItemCategories/ItemCategory.application.ts +++ b/packages/server-nest/src/modules/ItemCategories/ItemCategory.application.ts @@ -8,6 +8,7 @@ import { DeleteItemCategoryService } from './commands/DeleteItemCategory.service import { EditItemCategoryService } from './commands/EditItemCategory.service'; import { GetItemCategoryService } from './queries/GetItemCategory.service'; import { GetItemCategoriesService } from './queries/GetItemCategories.service'; +import { CreateItemCategoryDto, EditItemCategoryDto } from './dtos/ItemCategory.dto'; @Injectable() export class ItemCategoryApplication { @@ -31,7 +32,7 @@ export class ItemCategoryApplication { * @returns {Promise} The created item category. */ public createItemCategory( - itemCategoryDTO: IItemCategoryOTD, + itemCategoryDTO: CreateItemCategoryDto, ) { return this.createItemCategoryService.newItemCategory(itemCategoryDTO); } @@ -44,7 +45,7 @@ export class ItemCategoryApplication { */ public editItemCategory( itemCategoryId: number, - itemCategoryDTO: IItemCategoryOTD, + itemCategoryDTO: EditItemCategoryDto, ) { return this.editItemCategoryService.editItemCategory( itemCategoryId, diff --git a/packages/server-nest/src/modules/ItemCategories/ItemCategory.controller.ts b/packages/server-nest/src/modules/ItemCategories/ItemCategory.controller.ts index e78873ed6..7350819de 100644 --- a/packages/server-nest/src/modules/ItemCategories/ItemCategory.controller.ts +++ b/packages/server-nest/src/modules/ItemCategories/ItemCategory.controller.ts @@ -12,10 +12,13 @@ import { ItemCategoryApplication } from './ItemCategory.application'; import { GetItemCategoriesResponse, IItemCategoriesFilter, - IItemCategoryOTD, } from './ItemCategory.interfaces'; import { PublicRoute } from '../Auth/Jwt.guard'; import { ApiOperation, ApiTags } from '@nestjs/swagger'; +import { + CreateItemCategoryDto, + EditItemCategoryDto, +} from './dtos/ItemCategory.dto'; @Controller('item-categories') @ApiTags('item-categories') @@ -27,7 +30,7 @@ export class ItemCategoryController { @Post() @ApiOperation({ summary: 'Create a new item category.' }) - async createItemCategory(@Body() itemCategoryDTO: IItemCategoryOTD) { + async createItemCategory(@Body() itemCategoryDTO: CreateItemCategoryDto) { return this.itemCategoryApplication.createItemCategory(itemCategoryDTO); } @@ -43,7 +46,7 @@ export class ItemCategoryController { @ApiOperation({ summary: 'Edit the given item category.' }) async editItemCategory( @Param('id') id: number, - @Body() itemCategoryDTO: IItemCategoryOTD, + @Body() itemCategoryDTO: EditItemCategoryDto, ) { return this.itemCategoryApplication.editItemCategory(id, itemCategoryDTO); } diff --git a/packages/server-nest/src/modules/ItemCategories/commands/CreateItemCategory.service.ts b/packages/server-nest/src/modules/ItemCategories/commands/CreateItemCategory.service.ts index 4de5e4ccb..ac437ae9a 100644 --- a/packages/server-nest/src/modules/ItemCategories/commands/CreateItemCategory.service.ts +++ b/packages/server-nest/src/modules/ItemCategories/commands/CreateItemCategory.service.ts @@ -1,16 +1,12 @@ import { Injectable, Inject } from '@nestjs/common'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { Knex } from 'knex'; -import { - IItemCategoryOTD, - IItemCategoryCreatedPayload, -} from '../ItemCategory.interfaces'; +import { IItemCategoryCreatedPayload } from '../ItemCategory.interfaces'; import { events } from '@/common/events/events'; import { CommandItemCategoryValidatorService } from './CommandItemCategoryValidator.service'; import { ItemCategory } from '../models/ItemCategory.model'; import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; -import { SystemUser } from '@/modules/System/models/SystemUser'; -import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel'; +import { CreateItemCategoryDto } from '../dtos/ItemCategory.dto'; @Injectable() export class CreateItemCategoryService { @@ -31,12 +27,11 @@ export class CreateItemCategoryService { /** * Transforms OTD to model object. - * @param {IItemCategoryOTD} itemCategoryOTD - * @param {ISystemUser} authorizedUser - * @returns {ItemCategory} + * @param {CreateItemCategoryDto} itemCategoryOTD + * @returns {Partial} */ private transformOTDToObject( - itemCategoryOTD: IItemCategoryOTD, + itemCategoryOTD: CreateItemCategoryDto, ): Partial { return { ...itemCategoryOTD, @@ -47,10 +42,10 @@ export class CreateItemCategoryService { * Inserts a new item category. * @param {number} tenantId * @param {IItemCategoryOTD} itemCategoryOTD - * @return {Promise} + * @return {Promise} */ public async newItemCategory( - itemCategoryOTD: IItemCategoryOTD, + itemCategoryOTD: CreateItemCategoryDto, trx?: Knex.Transaction, ): Promise { // Validate the category name uniquiness. diff --git a/packages/server-nest/src/modules/ItemCategories/commands/EditItemCategory.service.ts b/packages/server-nest/src/modules/ItemCategories/commands/EditItemCategory.service.ts index 76e765d6f..4784a5eab 100644 --- a/packages/server-nest/src/modules/ItemCategories/commands/EditItemCategory.service.ts +++ b/packages/server-nest/src/modules/ItemCategories/commands/EditItemCategory.service.ts @@ -12,6 +12,7 @@ import { ItemCategory } from '../models/ItemCategory.model'; import { Inject } from '@nestjs/common'; import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { EditItemCategoryDto } from '../dtos/ItemCategory.dto'; export class EditItemCategoryService { /** @@ -33,14 +34,13 @@ export class EditItemCategoryService { /** * Edits item category. - * @param {number} tenantId - * @param {number} itemCategoryId - * @param {IItemCategoryOTD} itemCategoryOTD - * @return {Promise} + * @param {number} itemCategoryId - Item category id. + * @param {EditItemCategoryDto} itemCategoryOTD - Item category OTD. + * @return {Promise} */ public async editItemCategory( itemCategoryId: number, - itemCategoryOTD: IItemCategoryOTD, + itemCategoryOTD: EditItemCategoryDto, ): Promise { // Retrieve the item category from the storage. const oldItemCategory = await this.itemCategoryModel() @@ -90,11 +90,11 @@ export class EditItemCategoryService { /** * Transforms OTD to model object. - * @param {IItemCategoryOTD} itemCategoryOTD - * @param {ISystemUser} authorizedUser + * @param {EditItemCategoryDto} itemCategoryOTD + * @param {SystemUser} authorizedUser */ private transformOTDToObject( - itemCategoryOTD: IItemCategoryOTD, + itemCategoryOTD: EditItemCategoryDto, authorizedUser: SystemUser, ) { return { ...itemCategoryOTD, userId: authorizedUser.id }; diff --git a/packages/server-nest/src/modules/ItemCategories/dtos/ItemCategory.dto.ts b/packages/server-nest/src/modules/ItemCategories/dtos/ItemCategory.dto.ts index 921f6e27e..66b0ddac4 100644 --- a/packages/server-nest/src/modules/ItemCategories/dtos/ItemCategory.dto.ts +++ b/packages/server-nest/src/modules/ItemCategories/dtos/ItemCategory.dto.ts @@ -1,6 +1,36 @@ +import { IsNumber } from 'class-validator'; +import { IsOptional, IsString } from 'class-validator'; +import { IsNotEmpty } from 'class-validator'; +class CommandItemCategoryDto { + @IsString() + @IsNotEmpty() + name: string; + @IsString() + @IsOptional() + description?: string; -export class CreateItemCategoryDto {} + @IsNumber() + @IsNotEmpty() + userId: number; -export class EditItemCategoryDto {} + @IsNumber() + @IsOptional() + costAccountId?: number; + + @IsNumber() + @IsOptional() + sellAccountId?: number; + + @IsNumber() + @IsOptional() + inventoryAccountId?: number; + + @IsString() + @IsOptional() + costMethod?: string; +} + +export class CreateItemCategoryDto extends CommandItemCategoryDto {} +export class EditItemCategoryDto extends CommandItemCategoryDto {} diff --git a/packages/server-nest/src/modules/Organization/Organization.controller.ts b/packages/server-nest/src/modules/Organization/Organization.controller.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server-nest/src/modules/Organization/Organization.module.ts b/packages/server-nest/src/modules/Organization/Organization.module.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server-nest/src/modules/Organization/Organization/OrganizationBaseCurrencyLocking.ts b/packages/server-nest/src/modules/Organization/Organization/OrganizationBaseCurrencyLocking.ts new file mode 100644 index 000000000..ad3fd2783 --- /dev/null +++ b/packages/server-nest/src/modules/Organization/Organization/OrganizationBaseCurrencyLocking.ts @@ -0,0 +1,84 @@ +import { isEmpty } from 'lodash'; +import { Inject, Service } from 'typedi'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { TimeoutSettings } from 'puppeteer'; + +interface MutateBaseCurrencyLockMeta { + modelName: string; + pluralName?: string; +} + +@Service() +export default class OrganizationBaseCurrencyLocking { + @Inject() + tenancy: HasTenancyService; + + /** + * Retrieves the tenant models that have prevented mutation base currency. + */ + private getModelsPreventsMutate = (tenantId: number) => { + const Models = this.tenancy.models(tenantId); + + const filteredEntries = Object.entries(Models).filter( + ([key, Model]) => !!Model.preventMutateBaseCurrency + ); + return Object.fromEntries(filteredEntries); + }; + + /** + * Detarmines the mutation base currency model is locked. + * @param {Model} Model + * @returns {Promise} + */ + private isModelMutateLocked = async ( + Model + ): Promise => { + const validateQuery = Model.query(); + + if (typeof Model?.modifiers?.preventMutateBaseCurrency !== 'undefined') { + validateQuery.modify('preventMutateBaseCurrency'); + } else { + validateQuery.select(['id']).first(); + } + const validateResult = await validateQuery; + const isValid = !isEmpty(validateResult); + + return isValid + ? { + modelName: Model.name, + pluralName: Model.pluralName, + } + : false; + }; + + /** + * Retrieves the base currency mutation locks of the tenant models. + * @param {number} tenantId + * @returns {Promise} + */ + public async baseCurrencyMutateLocks( + tenantId: number + ): Promise { + const PreventedModels = this.getModelsPreventsMutate(tenantId); + + const opers = Object.entries(PreventedModels).map(([ModelName, Model]) => + this.isModelMutateLocked(Model) + ); + const results = await Promise.all(opers); + + return results.filter( + (result) => result !== false + ) as MutateBaseCurrencyLockMeta[]; + } + + /** + * Detarmines the base currency mutation locked. + * @param {number} tenantId + * @returns {Promise} + */ + public isBaseCurrencyMutateLocked = async (tenantId: number) => { + const locks = await this.baseCurrencyMutateLocks(tenantId); + + return !isEmpty(locks); + }; +} diff --git a/packages/server-nest/src/modules/Organization/Organization/OrganizationService.ts b/packages/server-nest/src/modules/Organization/Organization/OrganizationService.ts new file mode 100644 index 000000000..67c9656c8 --- /dev/null +++ b/packages/server-nest/src/modules/Organization/Organization/OrganizationService.ts @@ -0,0 +1,338 @@ +import { Service, Inject } from 'typedi'; +import { ObjectId } from 'mongodb'; +import { defaultTo, pick } from 'lodash'; +import { ServiceError } from '@/exceptions'; +import { + IOrganizationBuildDTO, + IOrganizationBuildEventPayload, + IOrganizationBuiltEventPayload, + IOrganizationUpdateDTO, + ISystemUser, + ITenant, +} from '@/interfaces'; +import { EventPublisher } from '@/lib/EventPublisher/EventPublisher'; +import events from '@/subscribers/events'; +import config from '../../config'; +import TenantsManager from '@/services/Tenancy/TenantsManager'; +import { Tenant } from '@/system/models'; +import OrganizationBaseCurrencyLocking from './OrganizationBaseCurrencyLocking'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { ERRORS } from './constants'; +import { initializeTenantSettings } from '@/api/middleware/SettingsMiddleware'; +import { initalizeTenantServices } from '@/api/middleware/TenantDependencyInjection'; + +@Service() +export default class OrganizationService { + @Inject() + private eventPublisher: EventPublisher; + + @Inject() + private tenantsManager: TenantsManager; + + @Inject('agenda') + private agenda: any; + + @Inject() + private baseCurrencyMutateLocking: OrganizationBaseCurrencyLocking; + + @Inject() + private tenancy: HasTenancyService; + + /** + * Builds the database schema and seed data of the given organization id. + * @param {srting} organizationId + * @return {Promise} + */ + public async build( + tenantId: number, + buildDTO: IOrganizationBuildDTO, + systemUser: ISystemUser + ): Promise { + const tenant = await this.getTenantOrThrowError(tenantId); + + // Throw error if the tenant is already initialized. + this.throwIfTenantInitizalized(tenant); + + // Drop the database if is already exists. + await this.tenantsManager.dropDatabaseIfExists(tenant); + + // Creates a new database. + await this.tenantsManager.createDatabase(tenant); + + // Migrate the tenant. + await this.tenantsManager.migrateTenant(tenant); + + // Migrated tenant. + const migratedTenant = await tenant.$query().withGraphFetched('metadata'); + + // Injects the given tenant IoC services. + await initalizeTenantServices(tenantId); + await initializeTenantSettings(tenantId); + + // Creates a tenancy object from given tenant model. + const tenancyContext = + this.tenantsManager.getSeedMigrationContext(migratedTenant); + + // Seed tenant. + await this.tenantsManager.seedTenant(migratedTenant, tenancyContext); + + // Throws `onOrganizationBuild` event. + await this.eventPublisher.emitAsync(events.organization.build, { + tenantId: tenant.id, + buildDTO, + systemUser, + } as IOrganizationBuildEventPayload); + + // Markes the tenant as completed builing. + await Tenant.markAsBuilt(tenantId); + await Tenant.markAsBuildCompleted(tenantId); + + // + await this.flagTenantDBBatch(tenantId); + + // Triggers the organization built event. + await this.eventPublisher.emitAsync(events.organization.built, { + tenantId: tenant.id, + } as IOrganizationBuiltEventPayload); + } + + /** + * + * @param {number} tenantId + * @param {IOrganizationBuildDTO} buildDTO + * @returns + */ + async buildRunJob( + tenantId: number, + buildDTO: IOrganizationBuildDTO, + authorizedUser: ISystemUser + ) { + const tenant = await this.getTenantOrThrowError(tenantId); + + // Throw error if the tenant is already initialized. + this.throwIfTenantInitizalized(tenant); + + // Throw error if tenant is currently building. + this.throwIfTenantIsBuilding(tenant); + + // Transformes build DTO object. + const transformedBuildDTO = this.transformBuildDTO(buildDTO); + + // Saves the tenant metadata. + await tenant.saveMetadata(transformedBuildDTO); + + // Send welcome mail to the user. + const jobMeta = await this.agenda.now('organization-setup', { + tenantId, + buildDTO, + authorizedUser, + }); + // Transformes the mangodb id to string. + const jobId = new ObjectId(jobMeta.attrs._id).toString(); + + // Markes the tenant as currently building. + await Tenant.markAsBuilding(tenantId, jobId); + + return { + nextRunAt: jobMeta.attrs.nextRunAt, + jobId: jobMeta.attrs._id, + }; + } + + /** + * Unlocks tenant build run job. + * @param {number} tenantId + * @param {number} jobId + */ + public async revertBuildRunJob(tenantId: number, jobId: string) { + await Tenant.markAsBuildCompleted(tenantId, jobId); + } + + /** + * Retrieve the current organization metadata. + * @param {number} tenantId + * @returns {Promise} + */ + public async currentOrganization(tenantId: number): Promise { + const tenant = await Tenant.query() + .findById(tenantId) + .withGraphFetched('subscriptions') + .withGraphFetched('metadata'); + + this.throwIfTenantNotExists(tenant); + + return tenant; + } + + /** + * Retrieve organization ability of mutate base currency + * @param {number} tenantId + * @returns + */ + public mutateBaseCurrencyAbility(tenantId: number) { + return this.baseCurrencyMutateLocking.baseCurrencyMutateLocks(tenantId); + } + + /** + * Updates organization information. + * @param {ITenant} tenantId + * @param {IOrganizationUpdateDTO} organizationDTO + */ + public async updateOrganization( + tenantId: number, + organizationDTO: IOrganizationUpdateDTO + ): Promise { + const tenant = await Tenant.query() + .findById(tenantId) + .withGraphFetched('metadata'); + + // Throw error if the tenant not exists. + this.throwIfTenantNotExists(tenant); + + // Validate organization transactions before mutate base currency. + if (organizationDTO.baseCurrency) { + await this.validateMutateBaseCurrency( + tenant, + organizationDTO.baseCurrency, + tenant.metadata?.baseCurrency + ); + } + await tenant.saveMetadata(organizationDTO); + + if (organizationDTO.baseCurrency !== tenant.metadata?.baseCurrency) { + // Triggers `onOrganizationBaseCurrencyUpdated` event. + await this.eventPublisher.emitAsync( + events.organization.baseCurrencyUpdated, + { + tenantId, + organizationDTO, + } + ); + } + } + + /** + * Transformes build DTO object. + * @param {IOrganizationBuildDTO} buildDTO + * @returns {IOrganizationBuildDTO} + */ + private transformBuildDTO( + buildDTO: IOrganizationBuildDTO + ): IOrganizationBuildDTO { + return { + ...buildDTO, + dateFormat: defaultTo(buildDTO.dateFormat, 'DD MMM yyyy'), + }; + } + + /** + * Throw base currency mutate locked error. + */ + private throwBaseCurrencyMutateLocked() { + throw new ServiceError(ERRORS.BASE_CURRENCY_MUTATE_LOCKED); + } + + /** + * Validate mutate base currency ability. + * @param {Tenant} tenant - + * @param {string} newBaseCurrency - + * @param {string} oldBaseCurrency - + */ + private async validateMutateBaseCurrency( + tenant: Tenant, + newBaseCurrency: string, + oldBaseCurrency: string + ) { + if (tenant.isReady && newBaseCurrency !== oldBaseCurrency) { + const isLocked = + await this.baseCurrencyMutateLocking.isBaseCurrencyMutateLocked( + tenant.id + ); + + if (isLocked) { + this.throwBaseCurrencyMutateLocked(); + } + } + } + + /** + * Throws error in case the given tenant is undefined. + * @param {ITenant} tenant + */ + private throwIfTenantNotExists(tenant: ITenant) { + if (!tenant) { + throw new ServiceError(ERRORS.TENANT_NOT_FOUND); + } + } + + /** + * Throws error in case the given tenant is already initialized. + * @param {ITenant} tenant + */ + private throwIfTenantInitizalized(tenant: ITenant) { + if (tenant.builtAt) { + throw new ServiceError(ERRORS.TENANT_ALREADY_BUILT); + } + } + + /** + * Throw error if the tenant is building. + * @param {ITenant} tenant + */ + private throwIfTenantIsBuilding(tenant) { + if (tenant.buildJobId) { + throw new ServiceError(ERRORS.TENANT_IS_BUILDING); + } + } + + /** + * Retrieve tenant of throw not found error. + * @param {number} tenantId - + */ + async getTenantOrThrowError(tenantId: number): Promise { + const tenant = await Tenant.query().findById(tenantId); + + if (!tenant) { + throw new ServiceError(ERRORS.TENANT_NOT_FOUND); + } + return tenant; + } + + /** + * Adds organization database latest batch number. + * @param {number} tenantId + * @param {number} version + */ + public async flagTenantDBBatch(tenantId: number) { + await Tenant.query() + .update({ + databaseBatch: config.databaseBatch, + }) + .where({ id: tenantId }); + } + + /** + * Syncs system user to tenant user. + */ + public async syncSystemUserToTenant( + tenantId: number, + systemUser: ISystemUser + ) { + const { User, Role } = this.tenancy.models(tenantId); + + const adminRole = await Role.query().findOne('slug', 'admin'); + + await User.query().insert({ + ...pick(systemUser, [ + 'firstName', + 'lastName', + 'phoneNumber', + 'email', + 'active', + 'inviteAcceptedAt', + ]), + systemUserId: systemUser.id, + roleId: adminRole.id, + }); + } +} diff --git a/packages/server-nest/src/modules/Organization/Organization/OrganizationUpgrade.ts b/packages/server-nest/src/modules/Organization/Organization/OrganizationUpgrade.ts new file mode 100644 index 000000000..9155539aa --- /dev/null +++ b/packages/server-nest/src/modules/Organization/Organization/OrganizationUpgrade.ts @@ -0,0 +1,102 @@ +import { Inject, Service } from 'typedi'; +import { ObjectId } from 'mongodb'; +import HasTenancyService from '@/services/Tenancy/TenancyService'; +import { SeedMigration } from '@/lib/Seeder/SeedMigration'; +import { Tenant } from '@/system/models'; +import { ServiceError } from '@/exceptions'; +import TenantDBManager from '@/services/Tenancy/TenantDBManager'; +import config from '../../config'; +import { ERRORS } from './constants'; +import OrganizationService from './OrganizationService'; +import TenantsManagerService from '@/services/Tenancy/TenantsManager'; + +@Service() +export default class OrganizationUpgrade { + @Inject() + private organizationService: OrganizationService; + + @Inject() + private tenantsManager: TenantsManagerService; + + @Inject('agenda') + private agenda: any; + + /** + * Upgrades the given organization database. + * @param {number} tenantId - Tenant id. + * @returns {Promise} + */ + public upgradeJob = async (tenantId: number): Promise => { + const tenant = await Tenant.query() + .findById(tenantId) + .withGraphFetched('metadata'); + + // Validate tenant version. + this.validateTenantVersion(tenant); + + // Initialize the tenant. + const seedContext = this.tenantsManager.getSeedMigrationContext(tenant); + + // Database manager. + const dbManager = new TenantDBManager(); + + // Migrate the organization database schema. + await dbManager.migrate(tenant); + + // Seeds the organization database data. + await new SeedMigration(seedContext.knex, seedContext).latest(); + + // Update the organization database version. + await this.organizationService.flagTenantDBBatch(tenantId); + + // Remove the tenant job id. + await Tenant.markAsUpgraded(tenantId); + }; + + /** + * Running organization upgrade job. + * @param {number} tenantId - Tenant id. + * @return {Promise} + */ + public upgrade = async (tenantId: number): Promise<{ jobId: string }> => { + const tenant = await Tenant.query().findById(tenantId); + + // Validate tenant version. + this.validateTenantVersion(tenant); + + // Validate tenant upgrade is not running. + this.validateTenantUpgradeNotRunning(tenant); + + // Send welcome mail to the user. + const jobMeta = await this.agenda.now('organization-upgrade', { + tenantId, + }); + // Transformes the mangodb id to string. + const jobId = new ObjectId(jobMeta.attrs._id).toString(); + + // Markes the tenant as currently building. + await Tenant.markAsUpgrading(tenantId, jobId); + + return { jobId }; + }; + + /** + * Validates the given tenant version. + * @param {ITenant} tenant + */ + private validateTenantVersion(tenant) { + if (tenant.databaseBatch >= config.databaseBatch) { + throw new ServiceError(ERRORS.TENANT_DATABASE_UPGRADED); + } + } + + /** + * Validates the given tenant upgrade is not running. + * @param tenant + */ + private validateTenantUpgradeNotRunning(tenant) { + if (tenant.isUpgradeRunning) { + throw new ServiceError(ERRORS.TENANT_UPGRADE_IS_RUNNING); + } + } +} diff --git a/packages/server-nest/src/modules/Organization/Organization/constants.ts b/packages/server-nest/src/modules/Organization/Organization/constants.ts new file mode 100644 index 000000000..a1c0bd6f7 --- /dev/null +++ b/packages/server-nest/src/modules/Organization/Organization/constants.ts @@ -0,0 +1,43 @@ +import currencies from 'js-money/lib/currency'; + +export const DATE_FORMATS = [ + 'MM.dd.yy', + 'dd.MM.yy', + 'yy.MM.dd', + 'MM.dd.yyyy', + 'dd.MM.yyyy', + 'yyyy.MM.dd', + 'MM/DD/YYYY', + 'M/D/YYYY', + 'dd MMM YYYY', + 'dd MMMM YYYY', + 'MMMM dd, YYYY', + 'EEE, MMMM dd, YYYY', +]; +export const MONTHS = [ + 'january', + 'february', + 'march', + 'april', + 'may', + 'june', + 'july', + 'august', + 'september', + 'october', + 'november', + 'december', +]; + +export const ACCEPTED_LOCALES = ['en', 'ar']; + +export const ERRORS = { + TENANT_DATABASE_UPGRADED: 'TENANT_DATABASE_UPGRADED', + TENANT_NOT_FOUND: 'tenant_not_found', + TENANT_ALREADY_BUILT: 'TENANT_ALREADY_BUILT', + TENANT_ALREADY_SEEDED: 'tenant_already_seeded', + TENANT_DB_NOT_BUILT: 'tenant_db_not_built', + TENANT_IS_BUILDING: 'TENANT_IS_BUILDING', + BASE_CURRENCY_MUTATE_LOCKED: 'BASE_CURRENCY_MUTATE_LOCKED', + TENANT_UPGRADE_IS_RUNNING: 'TENANT_UPGRADE_IS_RUNNING' +}; diff --git a/packages/server-nest/src/modules/Organization/commands/BuildOrganization.service.ts b/packages/server-nest/src/modules/Organization/commands/BuildOrganization.service.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server-nest/src/modules/Organization/commands/UpdateOrganization.service.ts b/packages/server-nest/src/modules/Organization/commands/UpdateOrganization.service.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server-nest/src/modules/Organization/queries/GetCurrentOrganization.service.ts b/packages/server-nest/src/modules/Organization/queries/GetCurrentOrganization.service.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server-nest/src/modules/Organization/queries/GetOrganizationBaseCurrencyLock.service.ts b/packages/server-nest/src/modules/Organization/queries/GetOrganizationBaseCurrencyLock.service.ts new file mode 100644 index 000000000..e69de29bb diff --git a/packages/server-nest/src/modules/PaymentLinks/CreateInvoiceCheckoutSession.ts b/packages/server-nest/src/modules/PaymentLinks/CreateInvoiceCheckoutSession.ts new file mode 100644 index 000000000..26504eaa2 --- /dev/null +++ b/packages/server-nest/src/modules/PaymentLinks/CreateInvoiceCheckoutSession.ts @@ -0,0 +1,104 @@ +import { StripePaymentService } from '../StripePayment/StripePaymentService'; +import { Inject, Injectable } from '@nestjs/common'; +import { TenantModelProxy } from '../System/models/TenantBaseModel'; +import { SaleInvoice } from '../SaleInvoices/models/SaleInvoice'; +import { PaymentLink } from './models/PaymentLink'; +import { StripeInvoiceCheckoutSessionPOJO } from '../StripePayment/StripePayment.types'; +import { ModelObject } from 'objection'; +import { ConfigService } from '@nestjs/config'; + +const origin = 'http://localhost'; + +@Injectable() +export class CreateInvoiceCheckoutSession { + constructor( + private readonly stripePaymentService: StripePaymentService, + private readonly configService: ConfigService, + + @Inject(SaleInvoice.name) + private readonly saleInvoiceModel: TenantModelProxy, + + @Inject(PaymentLink.name) + private readonly paymentLinkModel: typeof PaymentLink, + ) {} + + /** + * Creates a new Stripe checkout session from the given sale invoice. + * @param {number} saleInvoiceId - Sale invoice id. + * @returns {Promise} + */ + async createInvoiceCheckoutSession( + publicPaymentLinkId: string, + ): Promise { + // Retrieves the payment link from the given id. + const paymentLink = await this.paymentLinkModel + .query() + .findOne('linkId', publicPaymentLinkId) + .where('resourceType', 'SaleInvoice') + .throwIfNotFound(); + + // Retrieves the invoice from associated payment link. + const invoice = await this.saleInvoiceModel() + .query() + .findById(paymentLink.resourceId) + .withGraphFetched('paymentMethods') + .throwIfNotFound(); + + // It will be only one Stripe payment method associated to the invoice. + const stripePaymentMethod = invoice.paymentMethods?.find( + (method) => method.paymentIntegration?.service === 'Stripe', + ); + const stripeAccountId = stripePaymentMethod?.paymentIntegration?.accountId; + const paymentIntegrationId = stripePaymentMethod?.paymentIntegration?.id; + + // Creates checkout session for the given invoice. + const session = await this.createCheckoutSession(invoice, stripeAccountId, { + tenantId: paymentLink.tenantId, + paymentLinkId: paymentLink.id, + }); + return { + sessionId: session.id, + publishableKey: this.configService.get('stripePayment.publishableKey'), + redirectTo: session.url, + }; + } + + /** + * Creates a new Stripe checkout session for the given sale invoice. + * @param {ISaleInvoice} invoice - The sale invoice for which the checkout session is created. + * @param {string} stripeAccountId - The Stripe account ID associated with the payment method. + * @returns {Promise} - The created Stripe checkout session. + */ + private createCheckoutSession( + invoice: ModelObject, + stripeAccountId?: string, + metadata?: Record, + ) { + return this.stripePaymentService.stripe.checkout.sessions.create( + { + payment_method_types: ['card'], + line_items: [ + { + price_data: { + currency: invoice.currencyCode, + product_data: { + name: invoice.invoiceNo, + }, + unit_amount: invoice.total * 100, // Amount in cents + }, + quantity: 1, + }, + ], + mode: 'payment', + success_url: `${origin}/success`, + cancel_url: `${origin}/cancel`, + metadata: { + saleInvoiceId: invoice.id, + resource: 'SaleInvoice', + ...metadata, + }, + }, + { stripeAccount: stripeAccountId }, + ); + } +} diff --git a/packages/server-nest/src/modules/PaymentLinks/GetInvoicePaymentLinkMetadata.ts b/packages/server-nest/src/modules/PaymentLinks/GetInvoicePaymentLinkMetadata.ts new file mode 100644 index 000000000..89abc725e --- /dev/null +++ b/packages/server-nest/src/modules/PaymentLinks/GetInvoicePaymentLinkMetadata.ts @@ -0,0 +1,63 @@ +import moment from 'moment'; +import { Inject, Injectable } from '@nestjs/common'; +import { TenantModelProxy } from '../System/models/TenantBaseModel'; +import { SaleInvoice } from '../SaleInvoices/models/SaleInvoice'; +import { TransformerInjectable } from '../Transformer/TransformerInjectable.service'; +import { PaymentLink } from './models/PaymentLink'; +import { ServiceError } from '../Items/ServiceError'; +import { GetInvoicePaymentLinkMetaTransformer } from '../SaleInvoices/queries/GetInvoicePaymentLink.transformer'; +import { ClsService } from 'nestjs-cls'; +import { TenancyContext } from '../Tenancy/TenancyContext.service'; + +@Injectable() +export class GetInvoicePaymentLinkMetadata { + constructor( + private readonly transformer: TransformerInjectable, + private readonly clsService: ClsService, + private readonly tenancyContext: TenancyContext, + + @Inject(SaleInvoice.name) + private readonly saleInvoiceModel: TenantModelProxy, + + @Inject(PaymentLink.name) + private readonly paymentLinkModel: typeof PaymentLink, + ) {} + + /** + * Retrieves the invoice sharable link meta of the link id. + * @param {string} linkId - Link id. + */ + async getInvoicePaymentLinkMeta(linkId: string) { + const paymentLink = await this.paymentLinkModel.query() + .findOne('linkId', linkId) + .where('resourceType', 'SaleInvoice') + .throwIfNotFound(); + + // Validate the expiry at date. + if (paymentLink.expiryAt) { + const currentDate = moment(); + const expiryDate = moment(paymentLink.expiryAt); + + if (expiryDate.isBefore(currentDate)) { + throw new ServiceError('PAYMENT_LINK_EXPIRED'); + } + } + this.clsService.set('organizationId', paymentLink.tenantId); + this.clsService.set('userId', paymentLink.userId); + + const invoice = await this.saleInvoiceModel() + .query() + .findById(paymentLink.resourceId) + .withGraphFetched('entries.item') + .withGraphFetched('customer') + .withGraphFetched('taxes.taxRate') + .withGraphFetched('paymentMethods.paymentIntegration') + .withGraphFetched('pdfTemplate') + .throwIfNotFound(); + + return this.transformer.transform( + invoice, + new GetInvoicePaymentLinkMetaTransformer(), + ); + } +} diff --git a/packages/server-nest/src/modules/PaymentLinks/GetPaymentLinkInvoicePdf.ts b/packages/server-nest/src/modules/PaymentLinks/GetPaymentLinkInvoicePdf.ts new file mode 100644 index 000000000..7d0d50995 --- /dev/null +++ b/packages/server-nest/src/modules/PaymentLinks/GetPaymentLinkInvoicePdf.ts @@ -0,0 +1,34 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { SaleInvoicePdf } from '../SaleInvoices/queries/SaleInvoicePdf.service'; +import { PaymentLink } from './models/PaymentLink'; + +@Injectable() +export class GetPaymentLinkInvoicePdf { + constructor( + private readonly getSaleInvoicePdfService: SaleInvoicePdf, + + @Inject(PaymentLink.name) + private readonly paymentLinkModel: typeof PaymentLink, + ) {} + + /** + * Retrieves the sale invoice PDF of the given payment link id. + * @param {number} paymentLinkId + * @returns {Promise} + */ + async getPaymentLinkInvoicePdf( + paymentLinkId: string, + ): Promise<[Buffer, string]> { + const paymentLink = await this.paymentLinkModel.query() + .findOne('linkId', paymentLinkId) + .where('resourceType', 'SaleInvoice') + .throwIfNotFound(); + + const tenantId = paymentLink.tenantId; + await initalizeTenantServices(tenantId); + + const saleInvoiceId = paymentLink.resourceId; + + return this.getSaleInvoicePdfService.getSaleInvoicePdf(saleInvoiceId); + } +} diff --git a/packages/server-nest/src/modules/PaymentLinks/PaymentLinks.controller.ts b/packages/server-nest/src/modules/PaymentLinks/PaymentLinks.controller.ts new file mode 100644 index 000000000..de91132e7 --- /dev/null +++ b/packages/server-nest/src/modules/PaymentLinks/PaymentLinks.controller.ts @@ -0,0 +1,44 @@ +import { Response } from 'express'; +import { Controller, Get, Param, Res } from '@nestjs/common'; +import { PaymentLinksApplication } from './PaymentLinksApplication'; + +@Controller('payment-links') +export class PaymentLinksController { + constructor(private readonly paymentLinkApp: PaymentLinksApplication) {} + + @Get('/:paymentLinkId/invoice') + public async getPaymentLinkPublicMeta( + @Param('paymentLinkId') paymentLinkId: string, + ) { + const data = await this.paymentLinkApp.getInvoicePaymentLink(paymentLinkId); + + return { data }; + } + + @Get('/:paymentLinkId/stripe_checkout_session') + public async createInvoicePaymentLinkCheckoutSession( + @Param('paymentLinkId') paymentLinkId: string, + ) { + const session = + await this.paymentLinkApp.createInvoicePaymentCheckoutSession( + paymentLinkId, + ); + return session; + } + + @Get('/:paymentLinkId/invoice/pdf') + public async getPaymentLinkInvoicePdf( + @Param('paymentLinkId') paymentLinkId: string, + @Res() res: Response, + ) { + const [pdfContent, filename] = + await this.paymentLinkApp.getPaymentLinkInvoicePdf(paymentLinkId); + + res.set({ + 'Content-Type': 'application/pdf', + 'Content-Length': pdfContent.length, + 'Content-Disposition': `attachment; filename="${filename}"`, + }); + res.send(pdfContent); + } +} diff --git a/packages/server-nest/src/modules/PaymentLinks/PaymentLinks.module.ts b/packages/server-nest/src/modules/PaymentLinks/PaymentLinks.module.ts new file mode 100644 index 000000000..16ff91c43 --- /dev/null +++ b/packages/server-nest/src/modules/PaymentLinks/PaymentLinks.module.ts @@ -0,0 +1,21 @@ +import { Module } from '@nestjs/common'; +import { CreateInvoiceCheckoutSession } from './CreateInvoiceCheckoutSession'; +import { GetPaymentLinkInvoicePdf } from './GetPaymentLinkInvoicePdf'; +import { PaymentLinksApplication } from './PaymentLinksApplication'; +import { PaymentLinksController } from './PaymentLinks.controller'; +import { InjectSystemModel } from '../System/SystemModels/SystemModels.module'; +import { PaymentLink } from './models/PaymentLink'; + +const models = [InjectSystemModel(PaymentLink)]; + +@Module({ + providers: [ + ...models, + CreateInvoiceCheckoutSession, + GetPaymentLinkInvoicePdf, + PaymentLinksApplication, + ], + controllers: [PaymentLinksController], + exports: [...models, PaymentLinksApplication], +}) +export class PaymentLinksModule {} diff --git a/packages/server-nest/src/modules/PaymentLinks/PaymentLinksApplication.ts b/packages/server-nest/src/modules/PaymentLinks/PaymentLinksApplication.ts new file mode 100644 index 000000000..db94509f0 --- /dev/null +++ b/packages/server-nest/src/modules/PaymentLinks/PaymentLinksApplication.ts @@ -0,0 +1,51 @@ +import { Injectable } from '@nestjs/common'; +import { GetInvoicePaymentLinkMetadata } from './GetInvoicePaymentLinkMetadata'; +import { CreateInvoiceCheckoutSession } from './CreateInvoiceCheckoutSession'; +import { GetPaymentLinkInvoicePdf } from './GetPaymentLinkInvoicePdf'; +import { StripeInvoiceCheckoutSessionPOJO } from '../StripePayment/StripePayment.types'; + +@Injectable() +export class PaymentLinksApplication { + constructor( + private readonly getInvoicePaymentLinkMetadataService: GetInvoicePaymentLinkMetadata, + private readonly createInvoiceCheckoutSessionService: CreateInvoiceCheckoutSession, + private readonly getPaymentLinkInvoicePdfService: GetPaymentLinkInvoicePdf, + ) {} + + /** + * Retrieves the invoice payment link. + * @param {string} paymentLinkId + * @returns {} + */ + public getInvoicePaymentLink(paymentLinkId: string) { + return this.getInvoicePaymentLinkMetadataService.getInvoicePaymentLinkMeta( + paymentLinkId, + ); + } + + /** + * Create the invoice payment checkout session from the given payment link id. + * @param {string} paymentLinkId - Payment link id. + * @returns {Promise} + */ + public createInvoicePaymentCheckoutSession( + paymentLinkId: string, + ): Promise { + return this.createInvoiceCheckoutSessionService.createInvoiceCheckoutSession( + paymentLinkId, + ); + } + + /** + * Retrieves the sale invoice pdf of the given payment link id. + * @param {number} paymentLinkId + * @returns {Promise } + */ + public getPaymentLinkInvoicePdf( + paymentLinkId: string, + ): Promise<[Buffer, string]> { + return this.getPaymentLinkInvoicePdfService.getPaymentLinkInvoicePdf( + paymentLinkId, + ); + } +} diff --git a/packages/server-nest/src/modules/PaymentLinks/models/PaymentLink.ts b/packages/server-nest/src/modules/PaymentLinks/models/PaymentLink.ts index 08a23a992..388fbb845 100644 --- a/packages/server-nest/src/modules/PaymentLinks/models/PaymentLink.ts +++ b/packages/server-nest/src/modules/PaymentLinks/models/PaymentLink.ts @@ -1,6 +1,8 @@ +import { SystemModel } from '@/modules/System/models/SystemModel'; import { Model } from 'objection'; -export class PaymentLink extends Model { +export class PaymentLink extends SystemModel { + public id!: number; public tenantId!: number; public resourceId!: number; public resourceType!: string; diff --git a/packages/server-nest/src/modules/PaymentServices/models/PaymentIntegration.model.ts b/packages/server-nest/src/modules/PaymentServices/models/PaymentIntegration.model.ts index 71763e807..0d66d9d8c 100644 --- a/packages/server-nest/src/modules/PaymentServices/models/PaymentIntegration.model.ts +++ b/packages/server-nest/src/modules/PaymentServices/models/PaymentIntegration.model.ts @@ -1,8 +1,10 @@ import { BaseModel } from "@/models/Model"; export class PaymentIntegration extends BaseModel { - paymentEnabled!: boolean; - payoutEnabled!: boolean; + readonly service!: string; + readonly paymentEnabled!: boolean; + readonly payoutEnabled!: boolean; + readonly accountId!: string; static get tableName() { return 'payment_integrations'; @@ -24,6 +26,9 @@ export class PaymentIntegration extends BaseModel { return this.paymentEnabled && this.payoutEnabled; } + /** + * + */ static get modifiers() { return { /** diff --git a/packages/server-nest/src/modules/PaymentServices/models/TransactionPaymentServiceEntry.model.ts b/packages/server-nest/src/modules/PaymentServices/models/TransactionPaymentServiceEntry.model.ts index fd98aeb00..e1120dd84 100644 --- a/packages/server-nest/src/modules/PaymentServices/models/TransactionPaymentServiceEntry.model.ts +++ b/packages/server-nest/src/modules/PaymentServices/models/TransactionPaymentServiceEntry.model.ts @@ -1,7 +1,16 @@ import { Model } from 'objection'; import { BaseModel } from '@/models/Model'; +import { PaymentIntegration } from './PaymentIntegration.model'; export class TransactionPaymentServiceEntry extends BaseModel { + readonly referenceId!: number; + readonly referenceType!: string; + readonly paymentIntegrationId!: number; + readonly enable!: boolean; + readonly options!: Record; + + readonly paymentIntegration: PaymentIntegration; + /** * Table name */ diff --git a/packages/server-nest/src/modules/Roles/AbilitySchema.ts b/packages/server-nest/src/modules/Roles/AbilitySchema.ts new file mode 100644 index 000000000..8453332d6 --- /dev/null +++ b/packages/server-nest/src/modules/Roles/AbilitySchema.ts @@ -0,0 +1,332 @@ +import { ItemAction } from "@/interfaces/Item"; +import { ReportsAction } from "../FinancialStatements/types/Report.types"; +import { InventoryAdjustmentAction } from "../InventoryAdjutments/types/InventoryAdjustments.types"; +import { CashflowAction } from "../BankingTransactions/types/BankingTransactions.types"; +import { ManualJournalAction } from "../ManualJournals/types/ManualJournals.types"; +import { AccountAction } from "@/interfaces/Account"; +import { VendorCreditAction } from "../VendorCredit/types/VendorCredit.types"; +import { IPaymentMadeAction } from "../BillPayments/types/BillPayments.types"; +import { ExpenseAction } from "../Expenses/Expenses.types"; +import { CustomerAction, VendorAction } from "../Customers/types/Customers.types"; +import { SaleEstimateAction } from "../SaleEstimates/types/SaleEstimates.types"; +import { SaleInvoiceAction } from "../SaleInvoices/SaleInvoice.types"; +import { CreditNoteAction } from "../CreditNotes/types/CreditNotes.types"; +import { SaleReceiptAction } from "../SaleReceipts/types/SaleReceipts.types"; +import { BillAction } from "../Bills/Bills.types"; +import { AbilitySubject, ISubjectAbilitiesSchema } from "./Roles.types"; + +export const AbilitySchema: ISubjectAbilitiesSchema[] = [ + { + subject: AbilitySubject.Account, + subjectLabel: 'ability.accounts', + abilities: [ + { key: AccountAction.VIEW, label: 'ability.view' }, + { key: AccountAction.CREATE, label: 'ability.create' }, + { key: AccountAction.EDIT, label: 'ability.edit' }, + { key: AccountAction.DELETE, label: 'ability.delete' }, + ], + extraAbilities: [ + { + key: AccountAction.TransactionsLocking, + label: 'ability.transactions_locking', + }, + ], + }, + { + subject: AbilitySubject.ManualJournal, + subjectLabel: 'ability.manual_journal', + abilities: [ + { key: ManualJournalAction.View, label: 'ability.view' }, + { key: ManualJournalAction.Create, label: 'ability.create' }, + { key: ManualJournalAction.Edit, label: 'ability.edit' }, + { key: ManualJournalAction.Delete, label: 'ability.delete' }, + ], + }, + { + subject: AbilitySubject.Cashflow, + subjectLabel: 'ability.cashflow', + abilities: [ + { key: CashflowAction.View, label: 'ability.view' }, + { key: CashflowAction.Create, label: 'ability.create' }, + { key: CashflowAction.Delete, label: 'ability.delete' }, + ], + }, + { + subject: AbilitySubject.Item, + subjectLabel: 'ability.items', + abilities: [ + { key: ItemAction.VIEW, label: 'ability.view', default: true }, + { key: ItemAction.CREATE, label: 'ability.create', default: true }, + { key: ItemAction.EDIT, label: 'ability.edit', default: true }, + { key: ItemAction.DELETE, label: 'ability.delete', default: true }, + ], + }, + { + subject: AbilitySubject.InventoryAdjustment, + subjectLabel: 'ability.inventory_adjustment', + abilities: [ + { + key: InventoryAdjustmentAction.VIEW, + label: 'ability.view', + default: true, + }, + { + key: InventoryAdjustmentAction.CREATE, + label: 'ability.create', + default: true, + }, + { + key: InventoryAdjustmentAction.EDIT, + label: 'ability.edit', + default: true, + }, + { key: InventoryAdjustmentAction.DELETE, label: 'ability.delete' }, + ], + }, + { + subject: AbilitySubject.Customer, + subjectLabel: 'ability.customers', + // description: 'Description is here', + abilities: [ + { key: CustomerAction.View, label: 'ability.view', default: true }, + { key: CustomerAction.Create, label: 'ability.create', default: true }, + { key: CustomerAction.Edit, label: 'ability.edit', default: true }, + { key: CustomerAction.Delete, label: 'ability.delete', default: true }, + ], + }, + { + subject: AbilitySubject.Vendor, + subjectLabel: 'ability.vendors', + abilities: [ + { key: VendorAction.View, label: 'ability.view', default: true }, + { key: VendorAction.Create, label: 'ability.create', default: true }, + { key: VendorAction.Edit, label: 'ability.edit', default: true }, + { key: VendorAction.Delete, label: 'ability.delete', default: true }, + ], + }, + { + subject: AbilitySubject.SaleEstimate, + subjectLabel: 'ability.sale_estimates', + abilities: [ + { key: SaleEstimateAction.View, label: 'ability.view', default: true }, + { + key: SaleEstimateAction.Create, + label: 'ability.create', + default: true, + }, + { key: SaleEstimateAction.Edit, label: 'ability.edit', default: true }, + { + key: SaleEstimateAction.Delete, + label: 'ability.delete', + default: true, + }, + ], + }, + { + subject: AbilitySubject.SaleInvoice, + subjectLabel: 'ability.sale_invoices', + abilities: [ + { key: SaleInvoiceAction.View, label: 'ability.view', default: true }, + { key: SaleInvoiceAction.Create, label: 'ability.create', default: true }, + { key: SaleInvoiceAction.Edit, label: 'ability.edit', default: true }, + { key: SaleInvoiceAction.Delete, label: 'ability.delete', default: true }, + ], + extraAbilities: [{ key: 'bad-debt', label: 'Due amount to bad debit' }], + }, + { + subject: AbilitySubject.SaleReceipt, + subjectLabel: 'ability.sale_receipts', + abilities: [ + { key: SaleReceiptAction.View, label: 'ability.view', default: true }, + { key: SaleReceiptAction.Create, label: 'ability.create', default: true }, + { key: SaleReceiptAction.Edit, label: 'ability.edit', default: true }, + { key: SaleReceiptAction.Delete, label: 'ability.delete', default: true }, + ], + }, + { + subject: AbilitySubject.CreditNote, + subjectLabel: 'ability.credit_note', + abilities: [ + { key: CreditNoteAction.View, label: 'ability.view', default: true }, + { key: CreditNoteAction.Create, label: 'ability.create', default: true }, + { key: CreditNoteAction.Edit, label: 'ability.edit', default: true }, + { key: CreditNoteAction.Delete, label: 'ability.delete', default: true }, + { key: CreditNoteAction.Refund, label: 'ability.refund', default: true }, + ], + }, + { + subject: AbilitySubject.PaymentReceive, + subjectLabel: 'ability.payments_receive', + abilities: [ + { key: PaymentReceiveAction.View, label: 'ability.view', default: true }, + { + key: PaymentReceiveAction.Create, + label: 'ability.create', + default: true, + }, + { key: PaymentReceiveAction.Edit, label: 'ability.edit', default: true }, + { + key: PaymentReceiveAction.Delete, + label: 'ability.delete', + default: true, + }, + ], + }, + { + subject: AbilitySubject.Bill, + subjectLabel: 'ability.purchase_invoices', + abilities: [ + { key: BillAction.View, label: 'ability.view', default: true }, + { key: BillAction.Create, label: 'ability.create', default: true }, + { key: BillAction.Edit, label: 'ability.edit', default: true }, + { key: BillAction.Delete, label: 'ability.delete', default: true }, + ], + }, + { + subject: AbilitySubject.VendorCredit, + subjectLabel: 'ability.vendor_credit', + abilities: [ + { key: VendorCreditAction.View, label: 'ability.view', default: true }, + { + key: VendorCreditAction.Create, + label: 'ability.create', + default: true, + }, + { key: VendorCreditAction.Edit, label: 'ability.edit', default: true }, + { + key: VendorCreditAction.Delete, + label: 'ability.delete', + default: true, + }, + { + key: VendorCreditAction.Refund, + label: 'ability.refund', + default: true, + }, + ], + }, + { + subject: AbilitySubject.PaymentMade, + subjectLabel: 'ability.payments_made', + abilities: [ + { key: IPaymentMadeAction.View, label: 'ability.view', default: true }, + { + key: IPaymentMadeAction.Create, + label: 'ability.create', + default: true, + }, + { key: IPaymentMadeAction.Edit, label: 'ability.edit', default: true }, + { + key: IPaymentMadeAction.Delete, + label: 'ability.delete', + default: true, + }, + ], + }, + { + subject: AbilitySubject.Expense, + subjectLabel: 'ability.expenses', + abilities: [ + { key: ExpenseAction.View, label: 'ability.view', default: true }, + { key: ExpenseAction.Create, label: 'ability.create', default: true }, + { key: ExpenseAction.Edit, label: 'ability.edit', default: true }, + { key: ExpenseAction.Delete, label: 'ability.delete', default: true }, + ], + }, + { + subject: AbilitySubject.Report, + subjectLabel: 'ability.all_reports', + extraAbilities: [ + { + key: ReportsAction.READ_BALANCE_SHEET, + label: 'ability.balance_sheet_report', + }, + { + key: ReportsAction.READ_PROFIT_LOSS, + label: 'ability.profit_loss_sheet', + }, + { key: ReportsAction.READ_JOURNAL, label: 'ability.journal' }, + { + key: ReportsAction.READ_GENERAL_LEDGET, + label: 'ability.general_ledger', + }, + { key: ReportsAction.READ_CASHFLOW, label: 'ability.cashflow_report' }, + { + key: ReportsAction.READ_AR_AGING_SUMMARY, + label: 'ability.AR_aging_summary_report', + }, + { + key: ReportsAction.READ_AP_AGING_SUMMARY, + label: 'ability.AP_aging_summary_report', + }, + { + key: ReportsAction.READ_PURCHASES_BY_ITEMS, + label: 'ability.purchases_by_items', + }, + { + key: ReportsAction.READ_SALES_BY_ITEMS, + label: 'ability.sales_by_items_report', + }, + { + key: ReportsAction.READ_CUSTOMERS_TRANSACTIONS, + label: 'ability.customers_transactions_report', + }, + { + key: ReportsAction.READ_VENDORS_TRANSACTIONS, + label: 'ability.vendors_transactions_report', + }, + { + key: ReportsAction.READ_CUSTOMERS_SUMMARY_BALANCE, + label: 'ability.customers_summary_balance_report', + }, + { + key: ReportsAction.READ_VENDORS_SUMMARY_BALANCE, + label: 'ability.vendors_summary_balance_report', + }, + { + key: ReportsAction.READ_INVENTORY_VALUATION_SUMMARY, + label: 'ability.inventory_valuation_summary', + }, + { + key: ReportsAction.READ_INVENTORY_ITEM_DETAILS, + label: 'ability.inventory_items_details', + }, + ], + }, + { + subject: AbilitySubject.Preferences, + subjectLabel: 'ability.preferences', + extraAbilities: [ + { + key: PreferencesAction.Mutate, + label: 'ability.mutate_system_preferences', + }, + ], + }, +]; + +/** + * Retrieve the permissions subject. + * @param {string} key + * @returns {ISubjectAbilitiesSchema | null} + */ +export const getPermissionsSubject = ( + key: string +): ISubjectAbilitiesSchema | null => { + return AbilitySchema.find((subject) => subject.subject === key); +}; + +/** + * Retrieve the permission subject ability. + * @param {String} subjectKey + * @param {string} abilityKey + * @returns + */ +export const getPermissionAbility = ( + subjectKey: string, + abilityKey: string +): ISubjectAbilitySchema | null => { + const subject = getPermissionsSubject(subjectKey); + + return subject?.abilities.find((ability) => ability.key === abilityKey); +}; diff --git a/packages/server-nest/src/modules/Roles/Authorization.guard.ts b/packages/server-nest/src/modules/Roles/Authorization.guard.ts new file mode 100644 index 000000000..b9995371d --- /dev/null +++ b/packages/server-nest/src/modules/Roles/Authorization.guard.ts @@ -0,0 +1,56 @@ +import { + Injectable, + CanActivate, + ExecutionContext, + Inject, +} from '@nestjs/common'; +import { Request } from 'express'; +import { Reflector } from '@nestjs/core'; +import { ClsService } from 'nestjs-cls'; +import { ABILITIES_CACHE, getAbilityForRole } from './TenantAbilities'; +import { TenantModelProxy } from '../System/models/TenantBaseModel'; +import { TenantUser } from '../Tenancy/TenancyModels/models/TenantUser.model'; + +/** + * Authorization guard for checking user abilities + */ +@Injectable() +export class AuthorizationGuard implements CanActivate { + constructor( + private reflector: Reflector, + private readonly clsService: ClsService, + + @Inject(TenantUser.name) + private readonly tenantUserModel: TenantModelProxy, + ) {} + + /** + * Checks if the user has the required abilities to access the route + * @param context - The execution context + * @returns A boolean indicating if the user can access the route + */ + async canActivate(context: ExecutionContext): Promise { + const request = context.switchToHttp().getRequest(); + const { tenantId, user } = request as any; + + if (ABILITIES_CACHE.has(user.id)) { + (request as any).ability = ABILITIES_CACHE.get(user.id); + } else { + const ability = await this.getAbilityForUser(); + (request as any).ability = ability; + ABILITIES_CACHE.set(user.id, ability); + } + + return true; + } + + async getAbilityForUser() { + const userId = this.clsService.get('userId'); + const tenantUser = await this.tenantUserModel() + .query() + .findOne('systemUserId', userId) + .withGraphFetched('role.permissions'); + + return getAbilityForRole(tenantUser.role); + } +} diff --git a/packages/server-nest/src/modules/Roles/Roles.application.ts b/packages/server-nest/src/modules/Roles/Roles.application.ts new file mode 100644 index 000000000..c7a0714b7 --- /dev/null +++ b/packages/server-nest/src/modules/Roles/Roles.application.ts @@ -0,0 +1,62 @@ +import { Injectable } from '@nestjs/common'; +import { CreateRoleService } from './commands/CreateRole.service'; +import { DeleteRoleService } from './commands/DeleteRole.service'; +import { EditRoleService } from './commands/EditRole.service'; +import { GetRoleService } from './queries/GetRole.service'; +import { GetRolesService } from './queries/GetRoles.service'; + +@Injectable() +export class RolesApplication { + constructor( + private readonly createRoleService: CreateRoleService, + private readonly editRoleService: EditRoleService, + private readonly deleteRoleService: DeleteRoleService, + private readonly getRoleService: GetRoleService, + private readonly getRolesService: GetRolesService, + ) {} + + /** + * Creates a new role. + * @param createRoleDto The data for creating a new role. + * @returns The created role. + */ + async createRole(createRoleDto: any) { + return this.createRoleService.createRole(createRoleDto); + } + + /** + * Edits an existing role. + * @param roleId The ID of the role to edit. + * @param editRoleDto The data for editing the role. + * @returns The edited role. + */ + async editRole(roleId: number, editRoleDto: any) { + return this.editRoleService.editRole(roleId, editRoleDto); + } + + /** + * Deletes a role. + * @param roleId The ID of the role to delete. + * @returns The result of the deletion operation. + */ + async deleteRole(roleId: number) { + return this.deleteRoleService.deleteRole(roleId); + } + + /** + * Gets a specific role by ID. + * @param roleId The ID of the role to retrieve. + * @returns The requested role. + */ + async getRole(roleId: number) { + return this.getRoleService.getRole(roleId); + } + + /** + * Lists all roles. + * @returns A list of all roles. + */ + async getRoles() { + return this.getRolesService.getRoles(); + } +} diff --git a/packages/server-nest/src/modules/Roles/Roles.controller.ts b/packages/server-nest/src/modules/Roles/Roles.controller.ts new file mode 100644 index 000000000..bc91be0d5 --- /dev/null +++ b/packages/server-nest/src/modules/Roles/Roles.controller.ts @@ -0,0 +1,115 @@ +import { + Controller, + Post, + Get, + Delete, + Param, + Body, + Req, + Res, + Next, + HttpStatus, + ParseIntPipe, +} from '@nestjs/common'; +import { Response, NextFunction } from 'express'; +import { Injectable } from '@nestjs/common'; +import { CreateRoleDto, EditRoleDto } from './dtos/Role.dto'; +import { RolesApplication } from './Roles.application'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiParam, + ApiBody, +} from '@nestjs/swagger'; + +@ApiTags('Roles') +@Controller('roles') +@Injectable() +export class RolesController { + constructor(private readonly rolesApp: RolesApplication) {} + + @Post() + @ApiOperation({ summary: 'Create a new role' }) + @ApiBody({ type: CreateRoleDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Role created successfully', + }) + async createRole( + @Res() res: Response, + @Next() next: NextFunction, + @Body() createRoleDto: CreateRoleDto, + ) { + const role = await this.rolesApp.createRole(createRoleDto); + + return res.status(HttpStatus.OK).send({ + data: { roleId: role.id }, + message: 'The role has been created successfully.', + }); + } + + @Post(':id') + @ApiOperation({ summary: 'Edit an existing role' }) + @ApiParam({ name: 'id', description: 'Role ID' }) + @ApiBody({ type: EditRoleDto }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Role updated successfully', + }) + async editRole( + @Res() res: Response, + @Next() next: NextFunction, + @Param('id', ParseIntPipe) roleId: number, + @Body() editRoleDto: EditRoleDto, + ) { + const role = await this.rolesApp.editRole(roleId, editRoleDto); + + return res.status(HttpStatus.OK).send({ + data: { roleId }, + message: 'The given role has been updated successfully.', + }); + } + + @Delete(':id') + @ApiOperation({ summary: 'Delete a role' }) + @ApiParam({ name: 'id', description: 'Role ID' }) + @ApiResponse({ + status: HttpStatus.OK, + description: 'Role deleted successfully', + }) + async deleteRole( + @Res() res: Response, + @Next() next: NextFunction, + @Param('id', ParseIntPipe) roleId: number, + ) { + await this.rolesApp.deleteRole(roleId); + + return res.status(HttpStatus.OK).send({ + data: { roleId }, + message: 'The given role has been deleted successfully.', + }); + } + + @Get() + @ApiOperation({ summary: 'Get all roles' }) + @ApiResponse({ status: HttpStatus.OK, description: 'List of all roles' }) + async getRoles(@Res() res: Response) { + const roles = await this.rolesApp.getRoles(); + + return res.status(HttpStatus.OK).send({ roles }); + } + + @Get(':id') + @ApiOperation({ summary: 'Get a specific role by ID' }) + @ApiParam({ name: 'id', description: 'Role ID' }) + @ApiResponse({ status: HttpStatus.OK, description: 'Role details' }) + async getRole( + @Res() res: Response, + @Param('id', ParseIntPipe) roleId: number, + ) { + const role = await this.rolesApp.getRole(roleId); + + return res.status(HttpStatus.OK).send({ role }); + } +} diff --git a/packages/server-nest/src/modules/Roles/Roles.module.ts b/packages/server-nest/src/modules/Roles/Roles.module.ts new file mode 100644 index 000000000..602e9f1b0 --- /dev/null +++ b/packages/server-nest/src/modules/Roles/Roles.module.ts @@ -0,0 +1,31 @@ +import { Module } from '@nestjs/common'; +import { CreateRoleService } from './commands/CreateRole.service'; +import { EditRoleService } from './commands/EditRole.service'; +import { DeleteRoleService } from './commands/DeleteRole.service'; +import { GetRoleService } from './queries/GetRole.service'; +import { GetRolesService } from './queries/GetRoles.service'; +import { RegisterTenancyModel } from '../Tenancy/TenancyModels/Tenancy.module'; +import { Role } from './models/Role.model'; +import { RolePermission } from './models/RolePermission.model'; +import { RolesController } from './Roles.controller'; +import { RolesApplication } from './Roles.application'; + +const models = [ + RegisterTenancyModel(Role), + RegisterTenancyModel(RolePermission), +]; + +@Module({ + imports: [...models], + providers: [ + CreateRoleService, + EditRoleService, + DeleteRoleService, + GetRoleService, + GetRolesService, + RolesApplication, + ], + controllers: [RolesController], + exports: [...models], +}) +export class RolesModule {} diff --git a/packages/server-nest/src/modules/Roles/Roles.types.ts b/packages/server-nest/src/modules/Roles/Roles.types.ts new file mode 100644 index 000000000..258b76509 --- /dev/null +++ b/packages/server-nest/src/modules/Roles/Roles.types.ts @@ -0,0 +1,83 @@ +import { Knex } from 'knex'; +import { Ability, RawRuleOf, ForcedSubject } from '@casl/ability'; +import { CreateRoleDto, EditRoleDto } from './dtos/Role.dto'; +import { Role } from './models/Role.model'; + +export const actions = [ + 'manage', + 'create', + 'read', + 'update', + 'delete', +] as const; +export const subjects = ['Article', 'all'] as const; + +export type Abilities = [ + typeof actions[number], + ( + | typeof subjects[number] + | ForcedSubject> + ) +]; + +export type AppAbility = Ability; + +export const createAbility = (rules: RawRuleOf[]) => + new Ability(rules); + + +export interface ISubjectAbilitySchema { + key: string; + label: string; + default?: boolean; +} + +export interface ISubjectAbilitiesSchema { + subject: string; + subjectLabel: string; + description?: string; + abilities?: ISubjectAbilitySchema[]; + extraAbilities?: ISubjectAbilitySchema[]; +} + +export enum AbilitySubject { + Item = 'Item', + InventoryAdjustment = 'InventoryAdjustment', + Report = 'Report', + Account = 'Account', + SaleInvoice = 'SaleInvoice', + SaleEstimate = 'SaleEstimate', + SaleReceipt = 'SaleReceipt', + PaymentReceive = 'PaymentReceive', + Bill = 'Bill', + PaymentMade = 'PaymentMade', + Expense = 'Expense', + Customer = 'Customer', + Vendor = 'Vendor', + Cashflow = 'Cashflow', + ManualJournal = 'ManualJournal', + Preferences = 'Preferences', + CreditNote = 'CreditNode', + VendorCredit = 'VendorCredit', + Project = 'Project', + TaxRate = 'TaxRate' +} + +export interface IRoleCreatedPayload { + createRoleDTO: CreateRoleDto; + role: Role; + trx: Knex.Transaction; +} + +export interface IRoleEditedPayload { + editRoleDTO: EditRoleDto; + oldRole: Role; + role: Role; + trx: Knex.Transaction; +} + +export interface IRoleDeletedPayload { + oldRole: Role; + roleId: number; + trx: Knex.Transaction; +} diff --git a/packages/server-nest/src/modules/Roles/TenantAbilities.ts b/packages/server-nest/src/modules/Roles/TenantAbilities.ts new file mode 100644 index 000000000..2617f7dea --- /dev/null +++ b/packages/server-nest/src/modules/Roles/TenantAbilities.ts @@ -0,0 +1,55 @@ +import { Ability } from '@casl/ability'; +import LruCache from 'lru-cache'; +import { Role } from './models/Role.model'; +import { RolePermission } from './models/RolePermission.model'; + +// store abilities of 1000 most active users +export const ABILITIES_CACHE = new LruCache(1000); + +/** + * Retrieve ability for the given role. + * @param {} role + * @returns + */ +export function getAbilityForRole(role) { + const rules = getAbilitiesRolesConds(role); + return new Ability(rules); +} + +/** + * Retrieve abilities of the given role. + * @param {IRole} role + * @returns {} + */ +function getAbilitiesRolesConds(role: Role) { + switch (role.slug) { + case 'admin': // predefined role. + return getSuperAdminRules(); + default: + return getRulesFromRolePermissions(role.permissions || []); + } +} + +/** + * Retrieve the super admin rules. + * @returns {} + */ +function getSuperAdminRules() { + return [{ action: 'manage', subject: 'all' }]; +} + +/** + * Retrieve CASL rules from role permissions. + * @param {RolePermission[]} permissions - + * @returns {} + */ +function getRulesFromRolePermissions(permissions: RolePermission[]) { + return permissions + .filter((permission: RolePermission) => permission.value) + .map((permission: RolePermission) => { + return { + action: permission.ability, + subject: permission.subject, + }; + }); +} \ No newline at end of file diff --git a/packages/server-nest/src/modules/Roles/commands/CreateRole.service.ts b/packages/server-nest/src/modules/Roles/commands/CreateRole.service.ts new file mode 100644 index 000000000..19e7aaf5f --- /dev/null +++ b/packages/server-nest/src/modules/Roles/commands/CreateRole.service.ts @@ -0,0 +1,51 @@ +import { Knex } from 'knex'; +import { IRoleCreatedPayload } from '../Roles.types'; +import { Role } from './../models/Role.model'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { UnitOfWork } from '../../Tenancy/TenancyDB/UnitOfWork.service'; +import { events } from '@/common/events/events'; +import { CreateRoleDto } from '../dtos/Role.dto'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { Inject, Injectable } from '@nestjs/common'; + +@Injectable() +export class CreateRoleService { + constructor( + private readonly uow: UnitOfWork, + private readonly eventPublisher: EventEmitter2, + + @Inject(Role.name) + private readonly roleModel: TenantModelProxy, + ) {} + + /** + * Creates a new role and store it to the storage. + * @param {CreateRoleDto} createRoleDTO - + * @returns + */ + public async createRole(createRoleDTO: CreateRoleDto) { + // Validates the invalid permissions. + this.validateInvalidPermissions(createRoleDTO.permissions); + + // Transformes the permissions DTO. + const permissions = createRoleDTO.permissions; + + // Creates a new role with associated entries under unit-of-work. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Creates a new role to the storage. + const role = await this.roleModel().query(trx).upsertGraph({ + name: createRoleDTO.roleName, + description: createRoleDTO.roleDescription, + permissions, + }); + // Triggers `onRoleCreated` event. + await this.eventPublisher.emitAsync(events.roles.onCreated, { + createRoleDTO, + role, + trx, + } as IRoleCreatedPayload); + + return role; + }); + } +} diff --git a/packages/server-nest/src/modules/Roles/commands/DeleteRole.service.ts b/packages/server-nest/src/modules/Roles/commands/DeleteRole.service.ts new file mode 100644 index 000000000..ad0c322ce --- /dev/null +++ b/packages/server-nest/src/modules/Roles/commands/DeleteRole.service.ts @@ -0,0 +1,61 @@ + +import { Knex } from 'knex'; +import { + IRoleDeletedPayload, +} from '../Roles.types'; +import { TenantModelProxy } from '../../System/models/TenantBaseModel'; +import { Role } from '../models/Role.model'; +import { RolePermission } from '../models/RolePermission.model'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; +import { Inject, Injectable } from '@nestjs/common'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; + +@Injectable() +export class DeleteRoleService { + + constructor( + private readonly uow: UnitOfWork, + private readonly eventPublisher: EventEmitter2, + + @Inject(RolePermission.name) + private readonly rolePermissionModel: TenantModelProxy< + typeof RolePermission + >, + ) {} + + /** + * Deletes the given role from the storage. + * @param {number} roleId - Role id. + */ + public async deleteRole(roleId: number): Promise { + // Retrieve the given role or throw not found serice error. + const oldRole = await this.getRoleOrThrowError(roleId); + + // Validate role is not predefined. + this.validateRoleNotPredefined(oldRole); + + // Validates the given role is not associated to any user. + await this.validateRoleNotAssociatedToUser(roleId); + + // Deletes the given role and associated models under unit-of-work envirement. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Deletes the role associated permissions from the storage. + await this.rolePermissionModel() + .query(trx) + .where('roleId', roleId) + .delete(); + + // Deletes the role object form the storage. + await Role.query(trx).findById(roleId).delete(); + + // Triggers `onRoleDeleted` event. + await this.eventPublisher.emitAsync(events.roles.onDeleted, { + oldRole, + roleId, + trx, + } as IRoleDeletedPayload); + }); + } + +} diff --git a/packages/server-nest/src/modules/Roles/commands/EditRole.service.ts b/packages/server-nest/src/modules/Roles/commands/EditRole.service.ts new file mode 100644 index 000000000..769debc84 --- /dev/null +++ b/packages/server-nest/src/modules/Roles/commands/EditRole.service.ts @@ -0,0 +1,57 @@ +import { Knex } from 'knex'; +import { IRoleEditedPayload } from '../Roles.types'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { events } from '@/common/events/events'; +import { EditRoleDto } from '../dtos/Role.dto'; +import { Inject, Injectable } from '@nestjs/common'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { Role } from '../models/Role.model'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service'; + +@Injectable() +export class EditRoleService { + constructor( + private readonly uow: UnitOfWork, + private readonly eventPublisher: EventEmitter2, + private readonly transformer: TransformerInjectable, + + @Inject(Role.name) + private readonly roleModel: TenantModelProxy, + ) {} + + /** + * Edits details of the given role on the storage. + * @param {number} roleId - Role id. + * @param {IEditRoleDTO} editRoleDTO - Edit role DTO. + */ + public async editRole(roleId: number, editRoleDTO: EditRoleDto) { + // Validates the invalid permissions. + this.validateInvalidPermissions(editRoleDTO.permissions); + + // Retrieve the given role or throw not found serice error. + const oldRole = await this.getRoleOrThrowError(roleId); + + const permissions = editRoleDTO.permissions; + + // Updates the role on the storage. + return this.uow.withTransaction(async (trx: Knex.Transaction) => { + // Updates the given role to the storage. + const role = await this.roleModel().query(trx).upsertGraph({ + id: roleId, + name: editRoleDTO.roleName, + description: editRoleDTO.roleDescription, + permissions, + }); + // Triggers `onRoleEdited` event. + await this.eventPublisher.emitAsync(events.roles.onEdited, { + editRoleDTO, + oldRole, + role, + trx, + } as IRoleEditedPayload); + + return role; + }); + } +} diff --git a/packages/server-nest/src/modules/Roles/constants.ts b/packages/server-nest/src/modules/Roles/constants.ts new file mode 100644 index 000000000..39d1d768f --- /dev/null +++ b/packages/server-nest/src/modules/Roles/constants.ts @@ -0,0 +1,6 @@ +export const ERRORS = { + ROLE_NOT_FOUND: 'ROLE_NOT_FOUND', + ROLE_PREFINED: 'ROLE_PREFINED', + INVALIDATE_PERMISSIONS: 'INVALIDATE_PERMISSIONS', + CANNT_DELETE_ROLE_ASSOCIATED_TO_USERS: 'CANNT_DELETE_ROLE_ASSOCIATED_TO_USERS' +}; diff --git a/packages/server-nest/src/modules/Roles/dtos/Role.dto.ts b/packages/server-nest/src/modules/Roles/dtos/Role.dto.ts new file mode 100644 index 000000000..2ae8d2af6 --- /dev/null +++ b/packages/server-nest/src/modules/Roles/dtos/Role.dto.ts @@ -0,0 +1,46 @@ +import { Type } from 'class-transformer'; +import { + IsArray, + IsBoolean, + IsNotEmpty, + IsNumber, + IsString, + MinLength, + ValidateNested, +} from 'class-validator'; + +export class CommandRolePermissionDto { + @IsString() + @IsNotEmpty() + subject: string; + + @IsString() + @IsNotEmpty() + ability: string; + + @IsBoolean() + @IsNotEmpty() + value: boolean; + + @IsNumber() + permissionId: number; +} + +class CommandRoleDto { + @IsString() + @IsNotEmpty() + roleName: string; + + @IsString() + @IsNotEmpty() + roleDescription: string; + + @IsArray() + @ValidateNested({ each: true }) + @Type(() => CommandRolePermissionDto) + @MinLength(1) + permissions: Array; +} + +export class CreateRoleDto extends CommandRoleDto {} +export class EditRoleDto extends CommandRoleDto {} diff --git a/packages/server-nest/src/modules/Roles/models/Role.model.ts b/packages/server-nest/src/modules/Roles/models/Role.model.ts new file mode 100644 index 000000000..4a3e8b397 --- /dev/null +++ b/packages/server-nest/src/modules/Roles/models/Role.model.ts @@ -0,0 +1,39 @@ +import { Model, mixin } from 'objection'; +import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel'; +import RolePermission from './RolePermission.model'; + + +export class Role extends TenantBaseModel { + name: string; + description: string; + slug: string; + permissions: Array; + + /** + * Table name + */ + static get tableName() { + return 'roles'; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const { RolePermission } = require('./RolePermission'); + + return { + /** + * + */ + permissions: { + relation: Model.HasManyRelation, + modelClass: RolePermission, + join: { + from: 'roles.id', + to: 'role_permissions.roleId', + }, + }, + }; + } +} diff --git a/packages/server-nest/src/modules/Roles/models/RolePermission.model.ts b/packages/server-nest/src/modules/Roles/models/RolePermission.model.ts new file mode 100644 index 000000000..1f82b4fcc --- /dev/null +++ b/packages/server-nest/src/modules/Roles/models/RolePermission.model.ts @@ -0,0 +1,36 @@ +import { Model, mixin } from 'objection'; +import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel'; + +export class RolePermission extends TenantBaseModel { + value: any; + ability: any; + subject: any; + + /** + * Table name + */ + static get tableName() { + return 'role_permissions'; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const { Role } = require('./Role.model'); + + return { + /** + * + */ + role: { + relation: Model.BelongsToOneRelation, + modelClass: Role, + join: { + from: 'role_permissions.roleId', + to: 'roles.id', + }, + }, + }; + } +} diff --git a/packages/server-nest/src/modules/Roles/queries/GetRole.service.ts b/packages/server-nest/src/modules/Roles/queries/GetRole.service.ts new file mode 100644 index 000000000..85a2752e3 --- /dev/null +++ b/packages/server-nest/src/modules/Roles/queries/GetRole.service.ts @@ -0,0 +1,44 @@ +import { AbilitySchema } from '../AbilitySchema'; +import { RoleTransformer } from './RoleTransformer'; +import { Role } from '../models/Role.model'; +import { TransformerInjectable } from '../../Transformer/TransformerInjectable.service'; +import { ServiceError } from '../../Items/ServiceError'; +import { Injectable } from '@nestjs/common'; +import { CommandRolePermissionDto } from '../dtos/Role.dto'; +import { ERRORS } from '../constants'; + +@Injectable() +export class GetRoleService { + constructor(private readonly transformer: TransformerInjectable) {} + + /** + * Retrieve the given role metadata. + * @param {number} roleId - Role id. + * @returns {Promise} + */ + public async getRole(roleId: number): Promise { + const role = await Role.query() + .findById(roleId) + .withGraphFetched('permissions'); + + this.throwRoleNotFound(role); + + return this.transformer.transform(role, new RoleTransformer()); + } + + /** + * Validates the invalid given permissions. + * @param {ICreateRolePermissionDTO[]} permissions - + */ + public validateInvalidPermissions = ( + permissions: CommandRolePermissionDto[], + ) => { + const invalidPerms = getInvalidPermissions(AbilitySchema, permissions); + + if (invalidPerms.length > 0) { + throw new ServiceError(ERRORS.INVALIDATE_PERMISSIONS, null, { + invalidPermissions: invalidPerms, + }); + } + }; +} diff --git a/packages/server-nest/src/modules/Roles/queries/GetRoles.service.ts b/packages/server-nest/src/modules/Roles/queries/GetRoles.service.ts new file mode 100644 index 000000000..0bad07d74 --- /dev/null +++ b/packages/server-nest/src/modules/Roles/queries/GetRoles.service.ts @@ -0,0 +1,27 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { Role } from '../models/Role.model'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { RoleTransformer } from './RoleTransformer'; + +@Injectable() +export class GetRolesService { + constructor( + private readonly transformer: TransformerInjectable, + + @Inject(Role.name) + private readonly roleModel: TenantModelProxy, + ) {} + + /** + * Retrieve the roles list. + * @param {Promise} + */ + public getRoles = async (): Promise => { + const roles = await this.roleModel() + .query() + .withGraphFetched('permissions'); + + return this.transformer.transform(roles, new RoleTransformer()); + }; +} diff --git a/packages/server-nest/src/modules/Roles/queries/RolePermissionsSchema.ts b/packages/server-nest/src/modules/Roles/queries/RolePermissionsSchema.ts new file mode 100644 index 000000000..6a2b42441 --- /dev/null +++ b/packages/server-nest/src/modules/Roles/queries/RolePermissionsSchema.ts @@ -0,0 +1,12 @@ +import { AbilitySchema } from '../AbilitySchema'; +import { Injectable } from '@nestjs/common'; + +@Injectable() +export class RolePermissionsSchema { + /** + * Retrieve the role permissions schema. + */ + getRolePermissionsSchema() { + return AbilitySchema; + } +} diff --git a/packages/server-nest/src/modules/Roles/queries/RoleTransformer.ts b/packages/server-nest/src/modules/Roles/queries/RoleTransformer.ts new file mode 100644 index 000000000..b324a3a06 --- /dev/null +++ b/packages/server-nest/src/modules/Roles/queries/RoleTransformer.ts @@ -0,0 +1,31 @@ +import { Transformer } from '../../Transformer/Transformer'; + +export class RoleTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return ['name', 'description']; + }; + + /** + * Retrieves the localized role name if is predefined or stored name. + * @param role + * @returns {string} + */ + public name(role) { + return role.predefined ? this.context.i18n.t(role.name) : role.name; + } + + /** + * Retrieves the localized role description if is predefined or stored description. + * @param role + * @returns {string} + */ + public description(role) { + return role.predefined + ? this.context.i18n.t(role.description) + : role.description; + } +} diff --git a/packages/server-nest/src/modules/Roles/utils.ts b/packages/server-nest/src/modules/Roles/utils.ts new file mode 100644 index 000000000..5e539f3dd --- /dev/null +++ b/packages/server-nest/src/modules/Roles/utils.ts @@ -0,0 +1,42 @@ +import { keyBy } from 'lodash'; +import { ISubjectAbilitiesSchema } from './Roles.types'; + +/** + * Transformes ability schema to map. + */ +export function transformAbilitySchemaToMap(schema: ISubjectAbilitiesSchema[]) { + return keyBy( + schema.map((item) => ({ + ...item, + abilities: keyBy(item.abilities, 'key'), + extraAbilities: keyBy(item.extraAbilities, 'key'), + })), + 'subject' + ); +} + +/** + * Retrieve the invalid permissions from the given defined schema. + * @param {ISubjectAbilitiesSchema[]} schema + * @param permissions + * @returns + */ +export function getInvalidPermissions( + schema: ISubjectAbilitiesSchema[], + permissions +) { + const schemaMap = transformAbilitySchemaToMap(schema); + + return permissions.filter((permission) => { + const { subject, ability } = permission; + + if ( + !schemaMap[subject] || + (!schemaMap[subject].abilities[ability] && + !schemaMap[subject].extraAbilities[ability]) + ) { + return true; + } + return false; + }); +} diff --git a/packages/server-nest/src/modules/SaleInvoices/models/SaleInvoice.ts b/packages/server-nest/src/modules/SaleInvoices/models/SaleInvoice.ts index aa465e1f6..15e24fc71 100644 --- a/packages/server-nest/src/modules/SaleInvoices/models/SaleInvoice.ts +++ b/packages/server-nest/src/modules/SaleInvoices/models/SaleInvoice.ts @@ -12,6 +12,7 @@ import { Account } from '@/modules/Accounts/models/Account.model'; import { ISearchRole } from '@/modules/DynamicListing/DynamicFilter/DynamicFilter.types'; import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel'; import { PaymentIntegrationTransactionLink } from '../SaleInvoice.types'; +import { TransactionPaymentServiceEntry } from '@/modules/PaymentServices/models/TransactionPaymentServiceEntry.model'; export class SaleInvoice extends TenantBaseModel{ public taxAmountWithheld: number; @@ -52,8 +53,7 @@ export class SaleInvoice extends TenantBaseModel{ public entries!: ItemEntry[]; public attachments!: Document[]; public writtenoffExpenseAccount!: Account; - public paymentMethods!: PaymentIntegrationTransactionLink[]; - + public paymentMethods!: TransactionPaymentServiceEntry[]; /** * Table name */ diff --git a/packages/server-nest/src/modules/StripePayment/subscribers/StripeWebhooksSubscriber.ts b/packages/server-nest/src/modules/StripePayment/subscribers/StripeWebhooksSubscriber.ts index beadbcb73..1250c7d61 100644 --- a/packages/server-nest/src/modules/StripePayment/subscribers/StripeWebhooksSubscriber.ts +++ b/packages/server-nest/src/modules/StripePayment/subscribers/StripeWebhooksSubscriber.ts @@ -10,6 +10,7 @@ import { Inject, Injectable } from '@nestjs/common'; import { events } from '@/common/events/events'; import { PaymentIntegration } from '../models/PaymentIntegration.model'; import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { TenantModel } from '@/modules/System/models/TenantModel'; @Injectable() export class StripeWebhooksSubscriber { @@ -20,6 +21,9 @@ export class StripeWebhooksSubscriber { private readonly paymentIntegrationModel: TenantModelProxy< typeof PaymentIntegration >, + + @Inject(TenantModel.name) + private readonly tenantModel: typeof TenantModel ) {} /** @@ -34,6 +38,8 @@ export class StripeWebhooksSubscriber { const tenantId = parseInt(metadata.tenantId, 10); const saleInvoiceId = parseInt(metadata.saleInvoiceId, 10); + + // await initalizeTenantServices(tenantId); // await initializeTenantSettings(tenantId); @@ -63,7 +69,7 @@ export class StripeWebhooksSubscriber { if (!metadata?.paymentIntegrationId || !metadata.tenantId) return; // Find the tenant or throw not found error. - // await Tenant.query().findById(tenantId).throwIfNotFound(); + await this.tenantModel.query().findById(tenantId).throwIfNotFound(); // Check if the account capabilities are active if (account.capabilities.card_payments === 'active') { diff --git a/packages/server-nest/src/modules/Subscription/Subscription.module.ts b/packages/server-nest/src/modules/Subscription/Subscription.module.ts new file mode 100644 index 000000000..ee46b1f4b --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/Subscription.module.ts @@ -0,0 +1,34 @@ +import { Module } from '@nestjs/common'; +import { CancelLemonSubscription } from './commands/CancelLemonSubscription.service'; +import { ChangeLemonSubscription } from './commands/ChangeLemonSubscription.service'; +import { ResumeLemonSubscription } from './commands/ResumeLemonSubscription.service'; +import { LemonSqueezyWebhooks } from './webhooks/LemonSqueezyWebhooks'; +import { SubscribeFreeOnSignupCommunity } from './subscribers/SubscribeFreeOnSignupCommunity'; +import { TriggerInvalidateCacheOnSubscriptionChange } from './subscribers/TriggerInvalidateCacheOnSubscriptionChange'; +import { SubscriptionsController } from './Subscriptions.controller'; +import { SubscriptionsLemonWebhook } from './SubscriptionsLemonWebhook.controller'; +import { MarkSubscriptionPaymentFailed } from './commands/MarkSubscriptionPaymentFailed.service'; +import { MarkSubscriptionPaymentSucceed } from './commands/MarkSubscriptionPaymentSuccessed.service'; +import { InjectSystemModel } from '../System/SystemModels/SystemModels.module'; +import { PlanSubscription } from './models/PlanSubscription'; +import { Plan } from './models/Plan'; + + +const models = [InjectSystemModel(Plan), InjectSystemModel(PlanSubscription)] + +@Module({ + providers: [ + ...models, + CancelLemonSubscription, + ChangeLemonSubscription, + ResumeLemonSubscription, + LemonSqueezyWebhooks, + SubscribeFreeOnSignupCommunity, + TriggerInvalidateCacheOnSubscriptionChange, + MarkSubscriptionPaymentFailed, + MarkSubscriptionPaymentSucceed + ], + controllers: [SubscriptionsController, SubscriptionsLemonWebhook], + exports: [...models] +}) +export class SubscriptionModule {} diff --git a/packages/server-nest/src/modules/Subscription/SubscriptionApplication.ts b/packages/server-nest/src/modules/Subscription/SubscriptionApplication.ts new file mode 100644 index 000000000..e7d8cbaba --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/SubscriptionApplication.ts @@ -0,0 +1,148 @@ +import { Injectable } from '@nestjs/common'; +import { CancelLemonSubscription } from './commands/CancelLemonSubscription.service'; +import { ResumeLemonSubscription } from './commands/ResumeLemonSubscription.service'; +import { ChangeLemonSubscription } from './commands/ChangeLemonSubscription.service'; +import { MarkSubscriptionPaymentFailed } from './commands/MarkSubscriptionPaymentFailed.service'; +import { MarkSubscriptionPaymentSucceed } from './commands/MarkSubscriptionPaymentSuccessed.service'; +import { MarkSubscriptionCanceled } from './commands/MarkSubscriptionCanceled.service'; +import { NewSubscriptionService } from './commands/NewSubscription.service'; +import { SubscriptionPayload } from '@/interfaces/SubscriptionPlan'; +import { MarkSubscriptionPlanChanged } from './commands/MarkSubscriptionChanged.service'; +import { GetLemonSqueezyCheckoutService } from './queries/GetLemonSqueezyCheckout.service'; +import { GetSubscriptionsService } from './queries/GetSubscriptions.service'; +import { MarkSubscriptionResumedService } from './commands/MarkSubscriptionResumed.sevice'; + +@Injectable() +export class SubscriptionApplication { + constructor( + private readonly cancelSubscriptionService: CancelLemonSubscription, + private readonly resumeSubscriptionService: ResumeLemonSubscription, + private readonly changeSubscriptionPlanService: ChangeLemonSubscription, + private readonly markSubscriptionPaymentSuccessedService: MarkSubscriptionPaymentSucceed, + private readonly markSubscriptionPaymentFailedService: MarkSubscriptionPaymentFailed, + private readonly markSubscriptionCanceledService: MarkSubscriptionCanceled, + private readonly markSubscriptionPlanChangedService: MarkSubscriptionPlanChanged, + private readonly markSubscriptionResumedService: MarkSubscriptionResumedService, + private readonly createNewSubscriptionService: NewSubscriptionService, + private readonly getSubscriptionsService: GetSubscriptionsService, + private readonly getLemonSqueezyCheckoutService: GetLemonSqueezyCheckoutService, + ) {} + + /** + * + * @returns + */ + getSubscriptions() { + return this.getSubscriptionsService.getSubscriptions(); + } + + /** + * + * @param variantId + * @returns + */ + getLemonSqueezyCheckaoutUri(variantId: number) { + return this.getLemonSqueezyCheckoutService.getCheckout(variantId); + } + + /** + * Creates a new subscription of the current tenant. + * @param {string} planSlug + * @param {string} subscriptionSlug + * @param {SubscriptionPayload} payload + * @returns + */ + public createNewSubscription( + planSlug: string, + subscriptionSlug: string = 'main', + payload?: SubscriptionPayload, + ) { + return this.createNewSubscriptionService.execute( + planSlug, + subscriptionSlug, + payload, + ); + } + + /** + * Cancels the subscription of the given tenant. + * @param {string} id + * @returns {Promise} + */ + public cancelSubscription(subscriptionSlug: string = 'main') { + return this.cancelSubscriptionService.cancelSubscription(subscriptionSlug); + } + + /** + * Resumes the subscription of the given tenant. + * @returns {Promise} + */ + public resumeSubscription(subscriptionSlug: string = 'main') { + return this.resumeSubscriptionService.resumeSubscription(subscriptionSlug); + } + + /** + * Changes the given organization subscription plan. + * @param {number} newVariantId + * @returns {Promise} + */ + public changeSubscriptionPlan(newVariantId: number) { + return this.changeSubscriptionPlanService.changeSubscriptionPlan( + newVariantId, + ); + } + + /** + * + * @param subscriptionSlug + * @returns + */ + public markSubscriptionPaymentFailed(subscriptionSlug: string = 'main') { + return this.markSubscriptionPaymentFailedService.execute(subscriptionSlug); + } + + /** + * + * @param subscriptionSlug + * @returns + */ + public markSubscriptionPaymentSuccessed(subscriptionSlug: string = 'main') { + return this.markSubscriptionPaymentSuccessedService.execute( + subscriptionSlug, + ); + } + + /** + * Marks the given subscription slug canceled. + * @param {string} subscriptionSlug + * @returns + */ + public markSubscriptionCanceled(subscriptionSlug: string = 'main') { + return this.markSubscriptionCanceledService.execute(subscriptionSlug); + } + + /** + * + * @param {string} newPlanSlug + * @param {string} subscriptionSlug + * @returns + */ + public markSubscriptionPlanChanged( + newPlanSlug: string, + subscriptionSlug: string = 'main', + ) { + return this.markSubscriptionPlanChangedService.execute( + newPlanSlug, + subscriptionSlug, + ); + } + + /** + * Marks the given subscription slug resumed. + * @param {string} subscriptionSlug + * @returns + */ + markSubscriptionResumed(subscriptionSlug: string = 'main') { + return this.markSubscriptionResumedService.execute(subscriptionSlug); + } +} diff --git a/packages/server-nest/src/modules/Subscription/Subscriptions.controller.ts b/packages/server-nest/src/modules/Subscription/Subscriptions.controller.ts new file mode 100644 index 000000000..68232d2dc --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/Subscriptions.controller.ts @@ -0,0 +1,169 @@ +import { + Controller, + Post, + Get, + Body, + Req, + Res, + Next, + UseGuards, +} from '@nestjs/common'; +import { Request, Response, NextFunction } from 'express'; +import { SubscriptionApplication } from './SubscriptionApplication'; +import { + ApiOperation, + ApiTags, + ApiResponse, + ApiBody, +} from '@nestjs/swagger'; +import { Subscription } from './Subscription'; +import { LemonSqueezy } from './LemonSqueezy'; + +@Controller('subscription') +@ApiTags('subscriptions') +export class SubscriptionsController { + constructor( + private readonly subscriptionService: Subscription, + private readonly lemonSqueezyService: LemonSqueezy, + private readonly subscriptionApp: SubscriptionApplication, + ) {} + + /** + * Retrieve all subscriptions of the authenticated user's tenant. + */ + @Get() + @ApiOperation({ summary: 'Get all subscriptions for the current tenant' }) + @ApiResponse({ + status: 200, + description: 'List of subscriptions retrieved successfully', + }) + async getSubscriptions( + @Req() req: Request, + @Res() res: Response, + @Next() next: NextFunction, + ) { + const tenantId = req.headers['organization-id'] as string; + const subscriptions = + await this.subscriptionService.getSubscriptions(tenantId); + + return res.status(200).send({ subscriptions }); + } + + /** + * Retrieves the LemonSqueezy checkout url. + */ + @Post('lemon/checkout_url') + @ApiOperation({ summary: 'Get LemonSqueezy checkout URL' }) + @ApiBody({ + schema: { + type: 'object', + properties: { + variantId: { + type: 'string', + description: 'The variant ID for the subscription plan', + }, + }, + required: ['variantId'], + }, + }) + @ApiResponse({ + status: 200, + description: 'Checkout URL retrieved successfully', + }) + async getCheckoutUrl( + @Body('variantId') variantId: string, + @Req() req: Request, + @Res() res: Response, + @Next() next: NextFunction, + ) { + const user = req.user; + const checkout = await this.lemonSqueezyService.getCheckout( + variantId, + user, + ); + return res.status(200).send(checkout); + } + + /** + * Cancels the subscription of the current organization. + */ + @Post('cancel') + @ApiOperation({ summary: 'Cancel the current organization subscription' }) + @ApiResponse({ + status: 200, + description: 'Subscription canceled successfully', + }) + async cancelSubscription( + @Req() req: Request, + @Res() res: Response, + @Next() next: NextFunction, + ) { + const tenantId = req.headers['organization-id'] as string; + await this.subscriptionApp.cancelSubscription(tenantId); + + return res.status(200).send({ + status: 200, + message: 'The organization subscription has been canceled.', + }); + } + + /** + * Resumes the subscription of the current organization. + */ + @Post('resume') + @ApiOperation({ summary: 'Resume the current organization subscription' }) + @ApiResponse({ + status: 200, + description: 'Subscription resumed successfully', + }) + async resumeSubscription( + @Req() req: Request, + @Res() res: Response, + @Next() next: NextFunction, + ) { + const tenantId = req.headers['organization-id'] as string; + await this.subscriptionApp.resumeSubscription(tenantId); + + return res.status(200).send({ + status: 200, + message: 'The organization subscription has been resumed.', + }); + } + + /** + * Changes the main subscription plan of the current organization. + */ + @Post('change') + @ApiOperation({ + summary: 'Change the subscription plan of the current organization', + }) + @ApiBody({ + schema: { + type: 'object', + properties: { + variant_id: { + type: 'number', + description: 'The variant ID for the new subscription plan', + }, + }, + required: ['variant_id'], + }, + }) + @ApiResponse({ + status: 200, + description: 'Subscription plan changed successfully', + }) + async changeSubscriptionPlan( + @Body('variant_id') variantId: number, + @Req() req: Request, + @Res() res: Response, + @Next() next: NextFunction, + ) { + const tenantId = req.headers['organization-id'] as string; + await this.subscriptionApp.changeSubscriptionPlan(tenantId, variantId); + + return res.status(200).send({ + message: 'The subscription plan has been changed.', + }); + } +} diff --git a/packages/server-nest/src/modules/Subscription/SubscriptionsLemonWebhook.controller.ts b/packages/server-nest/src/modules/Subscription/SubscriptionsLemonWebhook.controller.ts new file mode 100644 index 000000000..68c13c53c --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/SubscriptionsLemonWebhook.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Post, Req, Res } from '@nestjs/common'; +import { LemonSqueezyWebhooks } from './webhooks/LemonSqueezyWebhooks'; + +@Controller('/webhooks/lemon') +export class SubscriptionsLemonWebhook { + constructor(private readonly lemonWebhooksService: LemonSqueezyWebhooks) {} + + /** + * Listens to Lemon Squeezy webhooks events. + * @param {Request} req + * @param {Response} res + * @returns {Response} + */ + @Post('/') + async lemonWebhooks(@Req() req: Request) { + const data = req.body; + const signature = (req.headers['x-signature'] as string) ?? ''; + const rawBody = req.rawBody; + + await this.lemonWebhooksService.handlePostWebhook(rawBody, data, signature); + } +} diff --git a/packages/server-nest/src/modules/Subscription/commands/CancelLemonSubscription.service.ts b/packages/server-nest/src/modules/Subscription/commands/CancelLemonSubscription.service.ts new file mode 100644 index 000000000..80bc2d752 --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/commands/CancelLemonSubscription.service.ts @@ -0,0 +1,52 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { cancelSubscription } from '@lemonsqueezy/lemonsqueezy.js'; +import { configureLemonSqueezy } from '../utils'; +import { ERRORS, IOrganizationSubscriptionCancel } from '../types'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { PlanSubscription } from '../models/PlanSubscription'; +import { events } from '@/common/events/events'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; + +@Injectable() +export class CancelLemonSubscription { + constructor( + public readonly eventEmitter: EventEmitter2, + public readonly tenancyContext: TenancyContext, + + @Inject(PlanSubscription.name) + private readonly planSubscriptionModel: typeof PlanSubscription, + ) {} + + /** + * Cancels the subscription of the given tenant. + * @param {number} subscriptionId - Subscription id. + * @returns {Promise} + */ + public async cancelSubscription( + subscriptionSlug: string = 'main', + ) { + configureLemonSqueezy(); + + const tenant = await this.tenancyContext.getTenant(); + const subscription = await this.planSubscriptionModel.query().findOne({ + tenantId: tenant.id, + slug: subscriptionSlug, + }); + if (!subscription) { + throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT); + } + const lemonSusbcriptionId = subscription.lemonSubscriptionId; + const subscriptionId = subscription.id; + const cancelledSub = await cancelSubscription(lemonSusbcriptionId); + + if (cancelledSub.error) { + throw new Error(cancelledSub.error.message); + } + // Triggers `onSubscriptionCancelled` event. + await this.eventEmitter.emitAsync( + events.subscription.onSubscriptionCancel, + { subscriptionId } as IOrganizationSubscriptionCancel, + ); + } +} diff --git a/packages/server-nest/src/modules/Subscription/commands/ChangeLemonSubscription.service.ts b/packages/server-nest/src/modules/Subscription/commands/ChangeLemonSubscription.service.ts new file mode 100644 index 000000000..f1a8bc34d --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/commands/ChangeLemonSubscription.service.ts @@ -0,0 +1,54 @@ + +import { updateSubscription } from '@lemonsqueezy/lemonsqueezy.js'; +import { configureLemonSqueezy } from '../utils'; +import { IOrganizationSubscriptionChanged } from '../types'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Inject, Injectable } from '@nestjs/common'; +import { PlanSubscription } from '../models/PlanSubscription'; +import { events } from '@/common/events/events'; +import { ServiceError } from '@/modules/Items/ServiceError'; + +@Injectable() +export class ChangeLemonSubscription { + constructor( + public readonly eventEmitter: EventEmitter2, + + @Inject(PlanSubscription.name) + private readonly planSubscriptionModel: typeof PlanSubscription, + ) {} + + /** + * Changes the given organization subscription plan. + * @param {number} tenantId - Tenant id. + * @param {number} newVariantId - New variant id. + * @returns {Promise} + */ + public async changeSubscriptionPlan( + newVariantId: number, + subscriptionSlug: string = 'main', + ) { + configureLemonSqueezy(); + + const subscription = await this.planSubscriptionModel.query().findOne({ + slug: subscriptionSlug, + }); + const lemonSubscriptionId = subscription.lemonSubscriptionId; + + // Send request to Lemon Squeezy to change the subscription. + const updatedSub = await updateSubscription(lemonSubscriptionId, { + variantId: newVariantId, + invoiceImmediately: true, + }); + if (updatedSub.error) { + throw new ServiceError('SOMETHING_WENT_WRONG'); + } + // Triggers `onSubscriptionPlanChanged` event. + await this.eventEmitter.emitAsync( + events.subscription.onSubscriptionPlanChange, + { + lemonSubscriptionId, + newVariantId, + } as IOrganizationSubscriptionChanged, + ); + } +} diff --git a/packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionCanceled.service.ts b/packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionCanceled.service.ts new file mode 100644 index 000000000..f680c9fb7 --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionCanceled.service.ts @@ -0,0 +1,46 @@ +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { PlanSubscription } from '../models/PlanSubscription'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { ERRORS } from '../types'; +import { events } from '@/common/events/events'; + +@Injectable() +export class MarkSubscriptionCanceled { + constructor( + public readonly eventEmitter: EventEmitter2, + public readonly tenancyContext: TenancyContext, + + @Inject(PlanSubscription.name) + private readonly planSubscriptionModel: typeof PlanSubscription, + ) {} + /** + * Cancels the given tenant subscription. + * @param {string} subscriptionSlug - Subscription slug. + */ + async execute(subscriptionSlug: string = 'main'): Promise { + const tenant = await this.tenancyContext.getTenant(); + const subscription = await this.planSubscriptionModel.query().findOne({ + tenantId: tenant.id, + slug: subscriptionSlug, + }); + // Throw error early if the subscription is not exist. + if (!subscription) { + throw new ServiceError(ERRORS.SUBSCRIPTION_NOT_EXIST); + } + // Throw error early if the subscription is already canceled. + if (subscription.canceled()) { + throw new ServiceError(ERRORS.SUBSCRIPTION_ALREADY_CANCELED); + } + await subscription.$query().patch({ canceledAt: new Date() }); + + // Triggers `onSubscriptionCancelled` event. + await this.eventEmitter.emitAsync( + events.subscription.onSubscriptionCancelled, + { + subscriptionSlug, + }, + ); + } +} \ No newline at end of file diff --git a/packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionChanged.service.ts b/packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionChanged.service.ts new file mode 100644 index 000000000..5939027c1 --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionChanged.service.ts @@ -0,0 +1,48 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { PlanSubscription } from '../models/PlanSubscription'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Plan } from '../models/Plan'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { events } from '@/common/events/events'; + +@Injectable() +export class MarkSubscriptionPlanChanged { + constructor( + public readonly eventEmitter: EventEmitter2, + public readonly tenancyContext: TenancyContext, + + @Inject(PlanSubscription.name) + private readonly planSubscriptionModel: typeof PlanSubscription, + ) {} + + /** + * Mark the given subscription payment of the tenant as succeed. + * @param {string} newPlanSlug - New plan slug. + * @param {string} subscriptionSlug - Subscription slug. + */ + async execute( + newPlanSlug: string, + subscriptionSlug: string = 'main', + ): Promise { + const tenant = await this.tenancyContext.getTenant(); + const newPlan = await Plan.query() + .findOne('slug', newPlanSlug) + .throwIfNotFound(); + + const subscription = await this.planSubscriptionModel.query().findOne({ + tenantId: tenant.id, + slug: subscriptionSlug, + }); + if (subscription.planId === newPlan.id) { + throw new ServiceError(''); + } + await subscription.$query().patch({ planId: newPlan.id }); + + // Triggers `onSubscriptionPlanChanged` event. + await this.eventEmitter.emitAsync( + events.subscription.onSubscriptionPlanChanged, + { newPlanSlug, subscriptionSlug }, + ); + } +} diff --git a/packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionPaymentFailed.service.ts b/packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionPaymentFailed.service.ts new file mode 100644 index 000000000..cdec3e369 --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionPaymentFailed.service.ts @@ -0,0 +1,43 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; +import { PlanSubscription } from '../models/PlanSubscription'; +import { SubscriptionPaymentStatus } from '@/interfaces/SubscriptionPlan'; +import { events } from '@/common/events/events'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; + +@Injectable() +export class MarkSubscriptionPaymentFailed { + constructor( + private readonly eventEmitter: EventEmitter2, + private readonly tenancyContext: TenancyContext, + + @Inject(PlanSubscription.name) + private readonly planSubscriptionModel: typeof PlanSubscription, + ) {} + + /** + * Marks the given subscription payment of the tenant as failed. + * @param {string} subscriptionSlug - Given subscription slug. + * @returns {Prmise} + */ + async execute(subscriptionSlug: string = 'main'): Promise { + const tenant = await this.tenancyContext.getTenant(); + const subscription = await this.planSubscriptionModel + .query() + .findOne({ tenantId: tenant.id, slug: subscriptionSlug }) + .throwIfNotFound(); + + await subscription + .$query() + .patch({ paymentStatus: SubscriptionPaymentStatus.Failed }); + + // Triggers `onSubscriptionPaymentFailed` event. + await this.eventEmitter.emitAsync( + events.subscription.onSubscriptionPaymentFailed, + { + subscriptionSlug, + }, + ); + } +} diff --git a/packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionPaymentSuccessed.service.ts b/packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionPaymentSuccessed.service.ts new file mode 100644 index 000000000..bf7a07814 --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionPaymentSuccessed.service.ts @@ -0,0 +1,42 @@ +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { PlanSubscription } from '../models/PlanSubscription'; +import { SubscriptionPaymentStatus } from '@/interfaces/SubscriptionPlan'; +import { events } from '@/common/events/events'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; + +@Injectable() +export class MarkSubscriptionPaymentSucceed { + constructor( + private readonly eventEmitter: EventEmitter2, + private readonly tenancyContext: TenancyContext, + + @Inject(PlanSubscription.name) + private readonly planSubscriptionModel: typeof PlanSubscription, + ) {} + + /** + * Marks the subscription payment as succeed. + * @param {string} subscriptionSlug - Given subscription slug by default main subscription. + * @returns {Promise} + */ + async execute(subscriptionSlug: string = 'main'): Promise { + const tenant = await this.tenancyContext.getTenant(); + const subscription = await this.planSubscriptionModel + .query() + .findOne({ tenantId: tenant.id, slug: subscriptionSlug }) + .throwIfNotFound(); + + await subscription + .$query() + .patch({ paymentStatus: SubscriptionPaymentStatus.Succeed }); + + // Triggers `onSubscriptionSucceed` event. + await this.eventEmitter.emitAsync( + events.subscription.onSubscriptionPaymentSucceed, + { + subscriptionSlug, + }, + ); + } +} diff --git a/packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionResumed.sevice.ts b/packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionResumed.sevice.ts new file mode 100644 index 000000000..23fa94c27 --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/commands/MarkSubscriptionResumed.sevice.ts @@ -0,0 +1,47 @@ +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { PlanSubscription } from '../models/PlanSubscription'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { ERRORS } from '../types'; +import { events } from '@/common/events/events'; + +@Injectable() +export class MarkSubscriptionResumedService { + constructor( + private readonly eventEmitter: EventEmitter2, + private readonly tenancyContext: TenancyContext, + + @Inject(PlanSubscription.name) + private readonly planSubscriptionModel: typeof PlanSubscription, + ) {} + + /** + * Resumes the given tenant subscription. + * @param {number} tenantId + * @param {string} subscriptionSlug - Subscription slug by deafult main subscription. + * @returns {Promise} + */ + async execute(subscriptionSlug: string = 'main') { + const tenant = await this.tenancyContext.getTenant(); + const subscription = await this.planSubscriptionModel.query().findOne({ + tenantId: tenant.id, + slug: subscriptionSlug, + }); + // Throw error early if the subscription is not exist. + if (!subscription) { + throw new ServiceError(ERRORS.SUBSCRIPTION_NOT_EXIST); + } + // Throw error early if the subscription is not cancelled. + if (!subscription.canceled()) { + throw new ServiceError(ERRORS.SUBSCRIPTION_ALREADY_ACTIVE); + } + await subscription.$query().patch({ canceledAt: null }); + + // Triggers `onSubscriptionResumed` event. + await this.eventEmitter.emitAsync( + events.subscription.onSubscriptionResumed, + { subscriptionSlug }, + ); + } +} diff --git a/packages/server-nest/src/modules/Subscription/commands/NewSubscription.service.ts b/packages/server-nest/src/modules/Subscription/commands/NewSubscription.service.ts new file mode 100644 index 000000000..1dff0e9c4 --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/commands/NewSubscription.service.ts @@ -0,0 +1,68 @@ +import { SubscriptionPayload } from '@/interfaces/SubscriptionPlan'; +import { Inject, Injectable } from '@nestjs/common'; +import { PlanSubscription } from '../models/PlanSubscription'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { Plan } from '../models/Plan'; + +@Injectable() +export class NewSubscriptionService { + constructor( + private readonly eventEmitter: EventEmitter2, + private readonly tenancyContext: TenancyContext, + + @Inject(PlanSubscription.name) + private readonly planSubscriptionModel: typeof PlanSubscription, + + @Inject(Plan.name) + private readonly planModel: typeof Plan, + ) {} + + /** + * Give the tenant a new subscription. + * @param {string} planSlug - Plan slug of the new subscription. + * @param {string} subscriptionSlug - Subscription slug by default takes main subscription + * @param {SubscriptionPayload} payload - Subscription payload. + */ + public async execute( + planSlug: string, + subscriptionSlug: string = 'main', + payload?: SubscriptionPayload, + ): Promise { + const tenant = await this.tenancyContext.getTenant(); + const plan = await this.planModel + .query() + .findOne('slug', planSlug) + .throwIfNotFound(); + + const isFree = plan.price === 0; + + // Take the invoice interval and period from the given plan. + const invoiceInterval = plan.invoiceInternal; + const invoicePeriod = isFree ? Infinity : plan.invoicePeriod; + + const subscription = await tenant + .$relatedQuery('subscriptions') + .modify('subscriptionBySlug', subscriptionSlug) + .first(); + + // No allowed to re-new the the subscription while the subscription is active. + if (subscription && subscription.active()) { + throw new NotAllowedChangeSubscriptionPlan(); + + // In case there is already subscription associated to the given tenant renew it. + } else if (subscription && subscription.inactive()) { + await subscription.renew(invoiceInterval, invoicePeriod); + + // No stored past tenant subscriptions create new one. + } else { + await tenant.newSubscription( + plan.id, + invoiceInterval, + invoicePeriod, + subscriptionSlug, + payload, + ); + } + } +} diff --git a/packages/server-nest/src/modules/Subscription/commands/ResumeLemonSubscription.service.ts b/packages/server-nest/src/modules/Subscription/commands/ResumeLemonSubscription.service.ts new file mode 100644 index 000000000..ebe44ec0d --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/commands/ResumeLemonSubscription.service.ts @@ -0,0 +1,51 @@ +import { updateSubscription } from '@lemonsqueezy/lemonsqueezy.js'; +import { configureLemonSqueezy } from '../utils'; +import { ERRORS, IOrganizationSubscriptionResume } from '../types'; +import { Inject, Injectable } from '@nestjs/common'; +import { EventEmitter2 } from '@nestjs/event-emitter'; +import { PlanSubscription } from '../models/PlanSubscription'; +import { ServiceError } from '@/modules/Items/ServiceError'; +import { events } from '@/common/events/events'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; + +@Injectable() +export class ResumeLemonSubscription { + constructor( + private readonly eventPublisher: EventEmitter2, + private readonly tenancyContext: TenancyContext, + + @Inject(PlanSubscription.name) + private readonly planSubscriptionModel: typeof PlanSubscription, + ) {} + + /** + * Resumes the main subscription of the given tenant. + * @param {string} subscriptionSlug - Subscription slug by default main subscription. + * @returns {Promise} + */ + public async resumeSubscription(subscriptionSlug: string = 'main') { + configureLemonSqueezy(); + + const tenant = await this.tenancyContext.getTenant(); + const subscription = await this.planSubscriptionModel.query().findOne({ + tenantId: tenant.id, + slug: subscriptionSlug, + }); + if (!subscription) { + throw new ServiceError(ERRORS.SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT); + } + const subscriptionId = subscription.id; + const lemonSubscriptionId = subscription.lemonSubscriptionId; + const returnedSub = await updateSubscription(lemonSubscriptionId, { + cancelled: false, + }); + if (returnedSub.error) { + throw new ServiceError(ERRORS.SOMETHING_WENT_WRONG_WITH_LS); + } + // Triggers `onSubscriptionResume` event. + await this.eventPublisher.emitAsync( + events.subscription.onSubscriptionResume, + { subscriptionId } as IOrganizationSubscriptionResume, + ); + } +} diff --git a/packages/server-nest/src/modules/Subscription/interceptors/Subscription.guard.ts b/packages/server-nest/src/modules/Subscription/interceptors/Subscription.guard.ts index db1f02f9e..8d67ca8c2 100644 --- a/packages/server-nest/src/modules/Subscription/interceptors/Subscription.guard.ts +++ b/packages/server-nest/src/modules/Subscription/interceptors/Subscription.guard.ts @@ -7,7 +7,6 @@ import { } from '@nestjs/common'; import { PlanSubscription } from '../models/PlanSubscription'; import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; -import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel'; @Injectable() export class SubscriptionGuard implements CanActivate { diff --git a/packages/server-nest/src/modules/Subscription/models/Plan.ts b/packages/server-nest/src/modules/Subscription/models/Plan.ts new file mode 100644 index 000000000..53b3a5467 --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/models/Plan.ts @@ -0,0 +1,87 @@ +import { SystemModel } from '@/modules/System/models/SystemModel'; +import { Model, mixin } from 'objection'; + +export class Plan extends mixin(SystemModel) { + public readonly price: number; + public readonly invoiceInternal: number; + public readonly invoicePeriod: string; + public readonly trialPeriod: string; + public readonly trialInterval: number; + + /** + * Table name. + */ + static get tableName() { + return 'subscription_plans'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['createdAt', 'updatedAt']; + } + + /** + * Defined virtual attributes. + */ + static get virtualAttributes() { + return ['isFree', 'hasTrial']; + } + + /** + * Model modifiers. + */ + static get modifiers() { + return { + getFeatureBySlug(builder, featureSlug) { + builder.where('slug', featureSlug); + }, + }; + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const { PlanSubscription } = require('./PlanSubscription'); + + return { + /** + * The plan may have many subscriptions. + */ + subscriptions: { + relation: Model.HasManyRelation, + modelClass: PlanSubscription, + join: { + from: 'subscription_plans.id', + to: 'subscription_plan_subscriptions.planId', + }, + }, + }; + } + + /** + * Check if plan is free. + * @return {boolean} + */ + isFree() { + return this.price <= 0; + } + + /** + * Check if plan is paid. + * @return {boolean} + */ + isPaid() { + return !this.isFree(); + } + + /** + * Check if plan has trial. + * @return {boolean} + */ + hasTrial() { + return this.trialPeriod && this.trialInterval; + } +} diff --git a/packages/server-nest/src/modules/Subscription/models/PlanSubscription.ts b/packages/server-nest/src/modules/Subscription/models/PlanSubscription.ts index 2112af793..4f96e297f 100644 --- a/packages/server-nest/src/modules/Subscription/models/PlanSubscription.ts +++ b/packages/server-nest/src/modules/Subscription/models/PlanSubscription.ts @@ -11,6 +11,7 @@ export class PlanSubscription extends mixin(SystemModel) { public readonly canceledAt: Date; public readonly trialEndsAt: Date; public readonly paymentStatus: SubscriptionPaymentStatus; + public readonly planId: number; /** * Table name. diff --git a/packages/server-nest/src/modules/Subscription/queries/GetLemonSqueezyCheckout.service.ts b/packages/server-nest/src/modules/Subscription/queries/GetLemonSqueezyCheckout.service.ts new file mode 100644 index 000000000..fa73d9ed2 --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/queries/GetLemonSqueezyCheckout.service.ts @@ -0,0 +1,49 @@ +import { createCheckout } from '@lemonsqueezy/lemonsqueezy.js'; +import { configureLemonSqueezy } from '../utils'; +import { Injectable } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { SystemUser } from '@/modules/System/models/SystemUser'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; + +@Injectable() +export class GetLemonSqueezyCheckoutService { + constructor(private readonly configService: ConfigService, + + private readonly tenancyContext: TenancyContext + ) {} + + /** + * Retrieves the LemonSqueezy checkout url. + * @param {number} variantId + * @param {SystemUser} user + */ + async getCheckout(variantId: number) { + configureLemonSqueezy(); + const user = await this.tenancyContext.getSystemUser(); + + return createCheckout( + this.configService.get('lemonSqueezy.storeId'), + variantId, + { + checkoutOptions: { + embed: true, + media: true, + logo: true, + }, + checkoutData: { + email: user.email, + custom: { + user_id: user.id + '', + tenant_id: user.tenantId + '', + }, + }, + productOptions: { + enabledVariants: [variantId], + redirectUrl: this.configService.get('lemonSqueezy.redirectTo'), + receiptButtonText: 'Go to Dashboard', + receiptThankYouNote: 'Thank you for signing up to Lemon Stand!', + }, + }, + ); + } +} diff --git a/packages/server-nest/src/modules/Subscription/queries/GetSubscriptions.service.ts b/packages/server-nest/src/modules/Subscription/queries/GetSubscriptions.service.ts new file mode 100644 index 000000000..5c5b96775 --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/queries/GetSubscriptions.service.ts @@ -0,0 +1,57 @@ +import { fromPairs } from 'lodash'; +import { Inject, Injectable } from '@nestjs/common'; +import { getSubscription } from '@lemonsqueezy/lemonsqueezy.js'; +import { PromisePool } from '@supercharge/promise-pool'; +import { GetSubscriptionsTransformer } from './GetSubscriptionsTransformer'; +import { configureLemonSqueezy } from '../utils'; +import { TransformerInjectable } from '@/modules/Transformer/TransformerInjectable.service'; +import { PlanSubscription } from '../models/PlanSubscription'; +import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service'; + +@Injectable() +export class GetSubscriptionsService { + constructor( + private readonly transformer: TransformerInjectable, + private readonly tenancyContext: TenancyContext, + + @Inject(PlanSubscription.name) + private readonly planSubscriptionModel: typeof PlanSubscription, + ) {} + + /** + * Retrieve all subscription of the given tenant. + * @param {number} tenantId + */ + public async getSubscriptions() { + configureLemonSqueezy(); + + const tenant = await this.tenancyContext.getTenant(); + const subscriptions = await this.planSubscriptionModel + .query() + .where('tenant_id', tenant.id) + .withGraphFetched('plan'); + + const lemonSubscriptionsResult = await PromisePool.withConcurrency(1) + .for(subscriptions) + .process(async (subscription, index, pool) => { + if (subscription.lemonSubscriptionId) { + const res = await getSubscription(subscription.lemonSubscriptionId); + + if (res.error) { + return; + } + return [subscription.lemonSubscriptionId, res.data]; + } + }); + const lemonSubscriptions = fromPairs( + lemonSubscriptionsResult?.results.filter((result) => !!result[1]), + ); + return this.transformer.transform( + subscriptions, + new GetSubscriptionsTransformer(), + { + lemonSubscriptions, + }, + ); + } +} diff --git a/packages/server-nest/src/modules/Subscription/queries/GetSubscriptionsTransformer.ts b/packages/server-nest/src/modules/Subscription/queries/GetSubscriptionsTransformer.ts new file mode 100644 index 000000000..7281cbb95 --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/queries/GetSubscriptionsTransformer.ts @@ -0,0 +1,168 @@ +import { Transformer } from "@/modules/Transformer/Transformer"; + +export class GetSubscriptionsTransformer extends Transformer { + /** + * Include these attributes to sale invoice object. + * @returns {Array} + */ + public includeAttributes = (): string[] => { + return [ + 'canceledAtFormatted', + 'endsAtFormatted', + 'trialStartsAtFormatted', + 'trialEndsAtFormatted', + 'statusFormatted', + 'planName', + 'planSlug', + 'planPrice', + 'planPriceCurrency', + 'planPriceFormatted', + 'planPeriod', + 'lemonUrls', + ]; + }; + + /** + * Exclude attributes. + * @returns {string[]} + */ + public excludeAttributes = (): string[] => { + return ['id', 'plan']; + }; + + /** + * Retrieves the canceled at formatted. + * @param subscription + * @returns {string} + */ + public canceledAtFormatted = (subscription) => { + return subscription.canceledAt + ? this.formatDate(subscription.canceledAt) + : null; + }; + + /** + * Retrieves the ends at date formatted. + * @param subscription + * @returns {string} + */ + public endsAtFormatted = (subscription) => { + return subscription.cancelsAt + ? this.formatDate(subscription.endsAt) + : null; + }; + + /** + * Retrieves the trial starts at formatted date. + * @returns {string} + */ + public trialStartsAtFormatted = (subscription) => { + return subscription.trialStartsAt + ? this.formatDate(subscription.trialStartsAt) + : null; + }; + + /** + * Retrieves the trial ends at formatted date. + * @returns {string} + */ + public trialEndsAtFormatted = (subscription) => { + return subscription.trialEndsAt + ? this.formatDate(subscription.trialEndsAt) + : null; + }; + + /** + * Retrieves the Lemon subscription metadata. + * @param subscription + * @returns + */ + public lemonSubscription = (subscription) => { + return ( + this.options.lemonSubscriptions[subscription.lemonSubscriptionId] || null + ); + }; + + /** + * Retrieves the formatted subscription status. + * @param subscription + * @returns {string} + */ + public statusFormatted = (subscription) => { + const pairs = { + canceled: 'Canceled', + active: 'Active', + inactive: 'Inactive', + expired: 'Expired', + on_trial: 'On Trial', + }; + return pairs[subscription.status] || ''; + }; + + /** + * Retrieves the subscription plan name. + * @param subscription + * @returns {string} + */ + public planName(subscription) { + return subscription.plan?.name; + } + + /** + * Retrieves the subscription plan slug. + * @param subscription + * @returns {string} + */ + public planSlug(subscription) { + return subscription.plan?.slug; + } + + /** + * Retrieves the subscription plan price. + * @param subscription + * @returns {number} + */ + public planPrice(subscription) { + return subscription.plan?.price; + } + + /** + * Retrieves the subscription plan price currency. + * @param subscription + * @returns {string} + */ + public planPriceCurrency(subscription) { + return subscription.plan?.currency; + } + + /** + * Retrieves the subscription plan formatted price. + * @param subscription + * @returns {string} + */ + public planPriceFormatted(subscription) { + return this.formatMoney(subscription.plan?.price, { + currencyCode: subscription.plan?.currency, + precision: 0 + }); + } + + /** + * Retrieves the subscription plan period. + * @param subscription + * @returns {string} + */ + public planPeriod(subscription) { + return subscription?.plan?.period; + } + + /** + * Retrieve the subscription Lemon Urls. + * @param subscription + * @returns + */ + public lemonUrls = (subscription) => { + const lemonSusbcription = this.lemonSubscription(subscription); + return lemonSusbcription?.data?.attributes?.urls; + }; +} diff --git a/packages/server-nest/src/modules/Subscription/subscribers/SubscribeFreeOnSignupCommunity.ts b/packages/server-nest/src/modules/Subscription/subscribers/SubscribeFreeOnSignupCommunity.ts new file mode 100644 index 000000000..37765255a --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/subscribers/SubscribeFreeOnSignupCommunity.ts @@ -0,0 +1,29 @@ +import { Subscription } from '../Subscription'; +import { OnEvent } from '@nestjs/event-emitter'; +import { Injectable } from '@nestjs/common'; +import { events } from '@/common/events/events'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class SubscribeFreeOnSignupCommunity { + constructor( + private readonly subscriptionService: Subscription, + private readonly configService: ConfigService, + ) {} + + /** + * Creates a new free subscription once the user signup if the app is self-hosted. + * @param {IAuthSignedUpEventPayload} + * @returns {Promise} + */ + @OnEvent(events.auth.signUp) + async subscribeFreeOnSigupCommunity({ + signupDTO, + tenant, + user, + }: IAuthSignedUpEventPayload) { + if (this.configService.get('hostedOnBigcapitalCloud')) return null; + + await this.subscriptionService.newSubscribtion(tenant.id, 'free'); + } +} diff --git a/packages/server-nest/src/modules/Subscription/subscribers/TriggerInvalidateCacheOnSubscriptionChange.ts b/packages/server-nest/src/modules/Subscription/subscribers/TriggerInvalidateCacheOnSubscriptionChange.ts new file mode 100644 index 000000000..09e77d34f --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/subscribers/TriggerInvalidateCacheOnSubscriptionChange.ts @@ -0,0 +1,16 @@ +import { events } from '@/common/events/events'; +import { Injectable } from '@nestjs/common'; +import { OnEvent } from '@nestjs/event-emitter'; + +@Injectable() +export class TriggerInvalidateCacheOnSubscriptionChange { + @OnEvent(events.subscription.onSubscriptionCancelled) + @OnEvent(events.subscription.onSubscriptionResumed) + @OnEvent(events.subscription.onSubscriptionPlanChanged) + triggerInvalidateCache() { + const io = Container.get('socket'); + + // Notify the frontend to reflect the new transactions changes. + io.emit('SUBSCRIPTION_CHANGED', { subscriptionSlug: 'main' }); + } +} diff --git a/packages/server-nest/src/modules/Subscription/types.ts b/packages/server-nest/src/modules/Subscription/types.ts new file mode 100644 index 000000000..09d585945 --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/types.ts @@ -0,0 +1,28 @@ +export const ERRORS = { + SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT: + 'SUBSCRIPTION_ID_NOT_ASSOCIATED_TO_TENANT', + SUBSCRIPTION_NOT_EXIST: 'SUBSCRIPTION_NOT_EXIST', + SUBSCRIPTION_ALREADY_CANCELED: 'SUBSCRIPTION_ALREADY_CANCELED', + SUBSCRIPTION_ALREADY_ACTIVE: 'SUBSCRIPTION_ALREADY_ACTIVE', + SOMETHING_WENT_WRONG_WITH_LS: 'SOMETHING_WENT_WRONG_WITH_LS', +}; + +export interface IOrganizationSubscriptionChanged { + lemonSubscriptionId: number; + newVariantId: number; +} + +export interface IOrganizationSubscriptionCancel { + subscriptionId: number; +} + +export interface IOrganizationSubscriptionCancelled { + subscriptionId: number; +} + +export interface IOrganizationSubscriptionResume { + subscriptionId: number; +} +export interface IOrganizationSubscriptionResumed { + subscriptionId: number; +} diff --git a/packages/server-nest/src/modules/Subscription/utils.ts b/packages/server-nest/src/modules/Subscription/utils.ts new file mode 100644 index 000000000..56dc7e4b4 --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/utils.ts @@ -0,0 +1,100 @@ +import { lemonSqueezySetup } from '@lemonsqueezy/lemonsqueezy.js'; + +/** + * Ensures that required environment variables are set and sets up the Lemon + * Squeezy JS SDK. Throws an error if any environment variables are missing or + * if there's an error setting up the SDK. + */ +export function configureLemonSqueezy() { + const requiredVars = [ + 'LEMONSQUEEZY_API_KEY', + 'LEMONSQUEEZY_STORE_ID', + 'LEMONSQUEEZY_WEBHOOK_SECRET', + ]; + const missingVars = requiredVars.filter((varName) => !process.env[varName]); + + if (missingVars.length > 0) { + throw new Error( + `Missing required LEMONSQUEEZY env variables: ${missingVars.join( + ', ' + )}. Please, set them in your .env file.` + ); + } + lemonSqueezySetup({ + apiKey: process.env.LEMONSQUEEZY_API_KEY, + onError: (error) => { + // eslint-disable-next-line no-console -- allow logging + console.error(error); + throw new Error(`Lemon Squeezy API error: ${error.message}`); + }, + }); +} +/** + * Check if the value is an object. + */ +function isObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null; +} + +/** + * Typeguard to check if the object has a 'meta' property + * and that the 'meta' property has the correct shape. + */ +export function webhookHasMeta(obj: unknown): obj is { + meta: { + event_name: string; + custom_data: { + user_id: string; + }; + }; +} { + if ( + isObject(obj) && + isObject(obj.meta) && + typeof obj.meta.event_name === 'string' && + isObject(obj.meta.custom_data) && + typeof obj.meta.custom_data.user_id === 'string' + ) { + return true; + } + return false; +} + +/** + * Typeguard to check if the object has a 'data' property and the correct shape. + * + * @param obj - The object to check. + * @returns True if the object has a 'data' property. + */ +export function webhookHasData(obj: unknown): obj is { + data: { + attributes: Record & { + first_subscription_item: { + id: number; + price_id: number; + is_usage_based: boolean; + }; + }; + id: string; + }; +} { + return ( + isObject(obj) && + 'data' in obj && + isObject(obj.data) && + 'attributes' in obj.data + ); +} + +export function createHmacSignature(secretKey, body) { + return require('crypto') + .createHmac('sha256', secretKey) + .update(body) + .digest('hex'); +} + +export function compareSignatures(signature, comparison_signature) { + const source = Buffer.from(signature, 'utf8'); + const comparison = Buffer.from(comparison_signature, 'utf8'); + return require('crypto').timingSafeEqual(source, comparison); +} diff --git a/packages/server-nest/src/modules/Subscription/webhooks/LemonSqueezyWebhooks.ts b/packages/server-nest/src/modules/Subscription/webhooks/LemonSqueezyWebhooks.ts new file mode 100644 index 000000000..329f26119 --- /dev/null +++ b/packages/server-nest/src/modules/Subscription/webhooks/LemonSqueezyWebhooks.ts @@ -0,0 +1,135 @@ +import { Injectable } from '@nestjs/common'; +import { + compareSignatures, + configureLemonSqueezy, + createHmacSignature, + webhookHasData, + webhookHasMeta, +} from '../utils'; +import { Subscription } from '../Subscription'; +import { ConfigService } from '@nestjs/config'; + +@Injectable() +export class LemonSqueezyWebhooks { + constructor( + private readonly subscriptionService: Subscription, + private readonly configService: ConfigService, + ) {} + + /** + * Handles the Lemon Squeezy webhooks. + * @param {string} rawBody + * @param {string} signature + * @returns {Promise} + */ + public async handlePostWebhook( + rawData: any, + data: Record, + signature: string, + ): Promise { + configureLemonSqueezy(); + + if (!this.configService.get('lemonSqueezy.webhookSecret')) { + throw new Error('Lemon Squeezy Webhook Secret not set in .env'); + } + if (!signature) { + throw new Error('Request signature is required.'); + } + const secret = this.configService.get('lemonSqueezy.webhookSecret'); + const hmacSignature = createHmacSignature(secret, rawData); + + if (!compareSignatures(hmacSignature, signature)) { + throw new Error('Invalid signature'); + } + // Type guard to check if the object has a 'meta' property. + if (webhookHasMeta(data)) { + // Non-blocking call to process the webhook event. + void this.processWebhookEvent(data); + } else { + throw new Error('Data invalid'); + } + } + + /** + * This action will process a webhook event in the database. + * @param {unknown} eventBody - + * @returns {Promise} + */ + private async processWebhookEvent(eventBody): Promise { + const webhookEvent = eventBody.meta.event_name; + + const userId = eventBody.meta.custom_data?.user_id; + const tenantId = eventBody.meta.custom_data?.tenant_id; + const subscriptionSlug = 'main'; + + if (!webhookHasMeta(eventBody)) { + throw new Error("Event body is missing the 'meta' property."); + } else if (webhookHasData(eventBody)) { + if (webhookEvent.startsWith('subscription_payment_')) { + // Marks the main subscription payment as succeed. + if (webhookEvent === 'subscription_payment_success') { + await this.subscriptionService.markSubscriptionPaymentSucceed( + tenantId, + subscriptionSlug, + ); + // Marks the main subscription payment as failed. + } else if (webhookEvent === 'subscription_payment_failed') { + await this.subscriptionService.markSubscriptionPaymentFailed( + tenantId, + subscriptionSlug, + ); + } + // Save subscription invoices; eventBody is a SubscriptionInvoice + // Not implemented. + } else if (webhookEvent.startsWith('subscription_')) { + // Save subscription events; obj is a Subscription + const attributes = eventBody.data.attributes; + const variantId = attributes.variant_id as string; + + // We assume that the Plan table is up to date. + const plan = await Plan.query().findOne('lemonVariantId', variantId); + + // Update the subscription in the database. + const priceId = attributes.first_subscription_item.price_id; + const subscriptionId = eventBody.data.id; + + // Throw error early if the given lemon variant id is not associated to any plan. + if (!plan) { + throw new Error(`Plan with variantId ${variantId} not found.`); + } + // Create a new subscription of the tenant. + if (webhookEvent === 'subscription_created') { + await this.subscriptionService.newSubscribtion( + tenantId, + plan.slug, + subscriptionSlug, + { lemonSqueezyId: subscriptionId }, + ); + // Cancel the given subscription of the organization. + } else if (webhookEvent === 'subscription_cancelled') { + await this.subscriptionService.cancelSubscription( + tenantId, + subscriptionSlug, + ); + } else if (webhookEvent === 'subscription_plan_changed') { + await this.subscriptionService.subscriptionPlanChanged( + tenantId, + plan.slug, + subscriptionSlug, + ); + } else if (webhookEvent === 'subscription_resumed') { + await this.subscriptionService.resumeSubscription( + tenantId, + subscriptionSlug, + ); + } + } else if (webhookEvent.startsWith('order_')) { + // Save orders; eventBody is a "Order" + /* Not implemented */ + } else if (webhookEvent.startsWith('license_')) { + // Save license keys; eventBody is a "License key" + /* Not implemented */ + } + } + } +} 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 48dbaf06a..f66709228 100644 --- a/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts +++ b/packages/server-nest/src/modules/Tenancy/TenancyModels/Tenancy.module.ts @@ -39,6 +39,7 @@ import { RefundVendorCredit } from '@/modules/VendorCreditsRefund/models/RefundV import { PaymentReceived } from '@/modules/PaymentReceived/models/PaymentReceived'; import { Model } from 'objection'; import { ClsModule } from 'nestjs-cls'; +import { TenantUser } from './models/TenantUser.model'; const models = [ Item, @@ -78,6 +79,7 @@ const models = [ RefundVendorCredit, PaymentReceived, PaymentReceivedEntry, + TenantUser, ]; /** diff --git a/packages/server-nest/src/modules/Tenancy/TenancyModels/models/TenantUser.model.ts b/packages/server-nest/src/modules/Tenancy/TenancyModels/models/TenantUser.model.ts new file mode 100644 index 000000000..03fb05ab4 --- /dev/null +++ b/packages/server-nest/src/modules/Tenancy/TenancyModels/models/TenantUser.model.ts @@ -0,0 +1,68 @@ +import { Model } from 'objection'; +import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel'; +import { Role } from '../../../Roles/models/Role.model'; + +export class TenantUser extends TenantBaseModel { + firstName!: string; + lastName!: string; + inviteAcceptedAt!: Date; + roleId!: number; + + role!: Role; + + /** + * Table name. + */ + static get tableName() { + return 'users'; + } + + /** + * Timestamps columns. + */ + get timestamps() { + return ['created_at', 'updated_at']; + } + + /** + * Virtual attributes. + */ + static get virtualAttributes() { + return ['isInviteAccepted', 'fullName']; + } + + /** + * Detarmines whether the user ivnite is accept. + */ + get isInviteAccepted() { + return !!this.inviteAcceptedAt; + } + + /** + * Full name attribute. + */ + get fullName() { + return `${this.firstName} ${this.lastName}`.trim(); + } + + /** + * Relationship mapping. + */ + static get relationMappings() { + const Role = require('models/Role'); + + return { + /** + * User belongs to user. + */ + role: { + relation: Model.BelongsToOneRelation, + modelClass: Role.default, + join: { + from: 'users.roleId', + to: 'roles.id', + }, + }, + }; + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index ab7e12023..c69d22611 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -496,6 +496,9 @@ importers: '@bigcapital/utils': specifier: '*' version: link:../../shared/bigcapital-utils + '@lemonsqueezy/lemonsqueezy.js': + specifier: ^2.2.0 + version: 2.2.0 '@liaoliaots/nestjs-redis': specifier: ^10.0.0 version: 10.0.0(@nestjs/common@10.4.7)(@nestjs/core@10.4.7)(ioredis@5.6.0)