refactor: subscriptions to nestjs
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -6,4 +6,5 @@ node_modules/
|
||||
# Production env file
|
||||
.env
|
||||
|
||||
test-results/
|
||||
test-results/
|
||||
.qodo
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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: [
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<typeof TenantUser>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Retrieve dashboard meta.
|
||||
* @param {number} tenantId
|
||||
* @param {number} authorizedUser
|
||||
*/
|
||||
public getBootMeta = async (): Promise<IDashboardBootMeta> => {
|
||||
// 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<IRoleAbility[]> => {
|
||||
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);
|
||||
};
|
||||
}
|
||||
46
packages/server-nest/src/modules/Export/Export.controller.ts
Normal file
46
packages/server-nest/src/modules/Export/Export.controller.ts
Normal file
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
12
packages/server-nest/src/modules/Export/Export.module.ts
Normal file
12
packages/server-nest/src/modules/Export/Export.module.ts
Normal file
@@ -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 {}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<string, string>} data
|
||||
* @param {string} sheetTitle
|
||||
@@ -21,7 +19,6 @@ export class ExportPdf {
|
||||
* @returns
|
||||
*/
|
||||
public async pdf(
|
||||
tenantId: number,
|
||||
columns: { accessor: string },
|
||||
data: Record<string, any>,
|
||||
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,
|
||||
});
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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<Record<string, any>>} data - The original data to be transformed.
|
||||
* @returns {Array<Record<string, any>>} - The transformed data.
|
||||
*/
|
||||
private transformExportedData(
|
||||
tenantId: number,
|
||||
resource: string,
|
||||
data: Array<Record<string, any>>
|
||||
): Array<Record<string, any>> {
|
||||
const resourceMeta = this.getResourceMeta(tenantId, resource);
|
||||
const resourceMeta = this.getResourceMeta(resource);
|
||||
|
||||
return R.when<Array<Record<string, any>>, Array<Record<string, any>>>(
|
||||
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({});
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import { IsNotEmpty, IsString } from 'class-validator';
|
||||
|
||||
export class ExportQuery {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
resource: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
format: string;
|
||||
}
|
||||
@@ -39,7 +39,7 @@ export class GeneralLedgerApplication {
|
||||
/**
|
||||
* Retrieves the G/L sheet in xlsx format.
|
||||
* @param {IGeneralLedgerSheetQuery} query
|
||||
* @returns {}
|
||||
* @returns {Promise<Buffer>}
|
||||
*/
|
||||
public xlsx(
|
||||
query: IGeneralLedgerSheetQuery,
|
||||
@@ -50,6 +50,7 @@ export class GeneralLedgerApplication {
|
||||
/**
|
||||
* Retrieves the G/L sheet in csv format.
|
||||
* @param {IGeneralLedgerSheetQuery} query -
|
||||
* @returns {Promise<string>}
|
||||
*/
|
||||
public csv(
|
||||
query: IGeneralLedgerSheetQuery,
|
||||
|
||||
@@ -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<ItemCategory>} 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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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<ItemCategory>}
|
||||
*/
|
||||
private transformOTDToObject(
|
||||
itemCategoryOTD: IItemCategoryOTD,
|
||||
itemCategoryOTD: CreateItemCategoryDto,
|
||||
): Partial<ItemCategory> {
|
||||
return {
|
||||
...itemCategoryOTD,
|
||||
@@ -47,10 +42,10 @@ export class CreateItemCategoryService {
|
||||
* Inserts a new item category.
|
||||
* @param {number} tenantId
|
||||
* @param {IItemCategoryOTD} itemCategoryOTD
|
||||
* @return {Promise<void>}
|
||||
* @return {Promise<ItemCategory>}
|
||||
*/
|
||||
public async newItemCategory(
|
||||
itemCategoryOTD: IItemCategoryOTD,
|
||||
itemCategoryOTD: CreateItemCategoryDto,
|
||||
trx?: Knex.Transaction,
|
||||
): Promise<ItemCategory> {
|
||||
// Validate the category name uniquiness.
|
||||
|
||||
@@ -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<void>}
|
||||
* @param {number} itemCategoryId - Item category id.
|
||||
* @param {EditItemCategoryDto} itemCategoryOTD - Item category OTD.
|
||||
* @return {Promise<ItemCategory>}
|
||||
*/
|
||||
public async editItemCategory(
|
||||
itemCategoryId: number,
|
||||
itemCategoryOTD: IItemCategoryOTD,
|
||||
itemCategoryOTD: EditItemCategoryDto,
|
||||
): Promise<ItemCategory> {
|
||||
// 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 };
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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<MutateBaseCurrencyLockMeta | false>}
|
||||
*/
|
||||
private isModelMutateLocked = async (
|
||||
Model
|
||||
): Promise<MutateBaseCurrencyLockMeta | false> => {
|
||||
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<MutateBaseCurrencyLockMeta[]>}
|
||||
*/
|
||||
public async baseCurrencyMutateLocks(
|
||||
tenantId: number
|
||||
): Promise<MutateBaseCurrencyLockMeta[]> {
|
||||
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<boolean>}
|
||||
*/
|
||||
public isBaseCurrencyMutateLocked = async (tenantId: number) => {
|
||||
const locks = await this.baseCurrencyMutateLocks(tenantId);
|
||||
|
||||
return !isEmpty(locks);
|
||||
};
|
||||
}
|
||||
@@ -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<void>}
|
||||
*/
|
||||
public async build(
|
||||
tenantId: number,
|
||||
buildDTO: IOrganizationBuildDTO,
|
||||
systemUser: ISystemUser
|
||||
): Promise<void> {
|
||||
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<ITenant[]>}
|
||||
*/
|
||||
public async currentOrganization(tenantId: number): Promise<ITenant> {
|
||||
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<void> {
|
||||
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<ITenant> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<void>}
|
||||
*/
|
||||
public upgradeJob = async (tenantId: number): Promise<void> => {
|
||||
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<void>}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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'
|
||||
};
|
||||
@@ -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<typeof SaleInvoice>,
|
||||
|
||||
@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<StripeInvoiceCheckoutSessionPOJO>}
|
||||
*/
|
||||
async createInvoiceCheckoutSession(
|
||||
publicPaymentLinkId: string,
|
||||
): Promise<StripeInvoiceCheckoutSessionPOJO> {
|
||||
// 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<any>} - The created Stripe checkout session.
|
||||
*/
|
||||
private createCheckoutSession(
|
||||
invoice: ModelObject<SaleInvoice>,
|
||||
stripeAccountId?: string,
|
||||
metadata?: Record<string, any>,
|
||||
) {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<typeof SaleInvoice>,
|
||||
|
||||
@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(),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<Buffer, string>}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<StripeInvoiceCheckoutSessionPOJO>}
|
||||
*/
|
||||
public createInvoicePaymentCheckoutSession(
|
||||
paymentLinkId: string,
|
||||
): Promise<StripeInvoiceCheckoutSessionPOJO> {
|
||||
return this.createInvoiceCheckoutSessionService.createInvoiceCheckoutSession(
|
||||
paymentLinkId,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the sale invoice pdf of the given payment link id.
|
||||
* @param {number} paymentLinkId
|
||||
* @returns {Promise<Buffer> }
|
||||
*/
|
||||
public getPaymentLinkInvoicePdf(
|
||||
paymentLinkId: string,
|
||||
): Promise<[Buffer, string]> {
|
||||
return this.getPaymentLinkInvoicePdfService.getPaymentLinkInvoicePdf(
|
||||
paymentLinkId,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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 {
|
||||
/**
|
||||
|
||||
@@ -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<string, any>;
|
||||
|
||||
readonly paymentIntegration: PaymentIntegration;
|
||||
|
||||
/**
|
||||
* Table name
|
||||
*/
|
||||
|
||||
332
packages/server-nest/src/modules/Roles/AbilitySchema.ts
Normal file
332
packages/server-nest/src/modules/Roles/AbilitySchema.ts
Normal file
@@ -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);
|
||||
};
|
||||
@@ -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<typeof TenantUser>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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<boolean> {
|
||||
const request = context.switchToHttp().getRequest<Request>();
|
||||
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);
|
||||
}
|
||||
}
|
||||
62
packages/server-nest/src/modules/Roles/Roles.application.ts
Normal file
62
packages/server-nest/src/modules/Roles/Roles.application.ts
Normal file
@@ -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();
|
||||
}
|
||||
}
|
||||
115
packages/server-nest/src/modules/Roles/Roles.controller.ts
Normal file
115
packages/server-nest/src/modules/Roles/Roles.controller.ts
Normal file
@@ -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 });
|
||||
}
|
||||
}
|
||||
31
packages/server-nest/src/modules/Roles/Roles.module.ts
Normal file
31
packages/server-nest/src/modules/Roles/Roles.module.ts
Normal file
@@ -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 {}
|
||||
83
packages/server-nest/src/modules/Roles/Roles.types.ts
Normal file
83
packages/server-nest/src/modules/Roles/Roles.types.ts
Normal file
@@ -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<Exclude<typeof subjects[number], 'all'>>
|
||||
)
|
||||
];
|
||||
|
||||
export type AppAbility = Ability<Abilities>;
|
||||
|
||||
export const createAbility = (rules: RawRuleOf<AppAbility>[]) =>
|
||||
new Ability<Abilities>(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;
|
||||
}
|
||||
55
packages/server-nest/src/modules/Roles/TenantAbilities.ts
Normal file
55
packages/server-nest/src/modules/Roles/TenantAbilities.ts
Normal file
@@ -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,
|
||||
};
|
||||
});
|
||||
}
|
||||
@@ -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<typeof Role>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
// 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);
|
||||
});
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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<typeof Role>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
6
packages/server-nest/src/modules/Roles/constants.ts
Normal file
6
packages/server-nest/src/modules/Roles/constants.ts
Normal file
@@ -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'
|
||||
};
|
||||
46
packages/server-nest/src/modules/Roles/dtos/Role.dto.ts
Normal file
46
packages/server-nest/src/modules/Roles/dtos/Role.dto.ts
Normal file
@@ -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<CommandRolePermissionDto>;
|
||||
}
|
||||
|
||||
export class CreateRoleDto extends CommandRoleDto {}
|
||||
export class EditRoleDto extends CommandRoleDto {}
|
||||
39
packages/server-nest/src/modules/Roles/models/Role.model.ts
Normal file
39
packages/server-nest/src/modules/Roles/models/Role.model.ts
Normal file
@@ -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<RolePermission>;
|
||||
|
||||
/**
|
||||
* 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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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<IRole>}
|
||||
*/
|
||||
public async getRole(roleId: number): Promise<Role> {
|
||||
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,
|
||||
});
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -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<typeof Role>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Retrieve the roles list.
|
||||
* @param {Promise<Role[]>}
|
||||
*/
|
||||
public getRoles = async (): Promise<Role[]> => {
|
||||
const roles = await this.roleModel()
|
||||
.query()
|
||||
.withGraphFetched('permissions');
|
||||
|
||||
return this.transformer.transform(roles, new RoleTransformer());
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
42
packages/server-nest/src/modules/Roles/utils.ts
Normal file
42
packages/server-nest/src/modules/Roles/utils.ts
Normal file
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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
|
||||
*/
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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 {}
|
||||
@@ -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<void>}
|
||||
*/
|
||||
public cancelSubscription(subscriptionSlug: string = 'main') {
|
||||
return this.cancelSubscriptionService.cancelSubscription(subscriptionSlug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Resumes the subscription of the given tenant.
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
public resumeSubscription(subscriptionSlug: string = 'main') {
|
||||
return this.resumeSubscriptionService.resumeSubscription(subscriptionSlug);
|
||||
}
|
||||
|
||||
/**
|
||||
* Changes the given organization subscription plan.
|
||||
* @param {number} newVariantId
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -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.',
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void>}
|
||||
*/
|
||||
async execute(subscriptionSlug: string = 'main'): Promise<void> {
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void>}
|
||||
*/
|
||||
async execute(subscriptionSlug: string = 'main'): Promise<void> {
|
||||
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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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<void> {
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<void>}
|
||||
*/
|
||||
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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
87
packages/server-nest/src/modules/Subscription/models/Plan.ts
Normal file
87
packages/server-nest/src/modules/Subscription/models/Plan.ts
Normal file
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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!',
|
||||
},
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
@@ -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<void>}
|
||||
*/
|
||||
@OnEvent(events.auth.signUp)
|
||||
async subscribeFreeOnSigupCommunity({
|
||||
signupDTO,
|
||||
tenant,
|
||||
user,
|
||||
}: IAuthSignedUpEventPayload) {
|
||||
if (this.configService.get('hostedOnBigcapitalCloud')) return null;
|
||||
|
||||
await this.subscriptionService.newSubscribtion(tenant.id, 'free');
|
||||
}
|
||||
}
|
||||
@@ -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' });
|
||||
}
|
||||
}
|
||||
28
packages/server-nest/src/modules/Subscription/types.ts
Normal file
28
packages/server-nest/src/modules/Subscription/types.ts
Normal file
@@ -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;
|
||||
}
|
||||
100
packages/server-nest/src/modules/Subscription/utils.ts
Normal file
100
packages/server-nest/src/modules/Subscription/utils.ts
Normal file
@@ -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<string, unknown> {
|
||||
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<string, unknown> & {
|
||||
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);
|
||||
}
|
||||
@@ -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<void>}
|
||||
*/
|
||||
public async handlePostWebhook(
|
||||
rawData: any,
|
||||
data: Record<string, any>,
|
||||
signature: string,
|
||||
): Promise<void> {
|
||||
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<void>}
|
||||
*/
|
||||
private async processWebhookEvent(eventBody): Promise<void> {
|
||||
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 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
];
|
||||
|
||||
/**
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
3
pnpm-lock.yaml
generated
3
pnpm-lock.yaml
generated
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user