mirror of
https://github.com/bigcapitalhq/bigcapital.git
synced 2026-02-23 16:19:49 +00:00
refactor: subscriptions to nestjs
This commit is contained in:
1
.gitignore
vendored
1
.gitignore
vendored
@@ -7,3 +7,4 @@ node_modules/
|
|||||||
.env
|
.env
|
||||||
|
|
||||||
test-results/
|
test-results/
|
||||||
|
.qodo
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"@types/nodemailer": "^6.4.17",
|
"@types/nodemailer": "^6.4.17",
|
||||||
"@types/passport-local": "^1.0.38",
|
"@types/passport-local": "^1.0.38",
|
||||||
"@types/ramda": "^0.30.2",
|
"@types/ramda": "^0.30.2",
|
||||||
|
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
|
||||||
"accounting": "^0.4.1",
|
"accounting": "^0.4.1",
|
||||||
"async": "^3.2.0",
|
"async": "^3.2.0",
|
||||||
"async-mutex": "^0.5.0",
|
"async-mutex": "^0.5.0",
|
||||||
|
|||||||
@@ -1,32 +1,29 @@
|
|||||||
// import { Inject, Service } from 'typedi';
|
import { AccountsApplication } from './AccountsApplication.service';
|
||||||
// import { AccountsApplication } from './AccountsApplication.service';
|
import { Exportable } from '../Export/Exportable';
|
||||||
// import { Exportable } from '../Export/Exportable';
|
import { EXPORT_SIZE_LIMIT } from '../Export/constants';
|
||||||
// import { IAccountsFilter, IAccountsStructureType } from '@/interfaces';
|
import { IAccountsFilter, IAccountsStructureType } from './Accounts.types';
|
||||||
// import { EXPORT_SIZE_LIMIT } from '../Export/constants';
|
|
||||||
|
|
||||||
// @Service()
|
export class AccountsExportable extends Exportable {
|
||||||
// export class AccountsExportable extends Exportable {
|
constructor(private readonly accountsApplication: AccountsApplication) {
|
||||||
// @Inject()
|
super();
|
||||||
// private accountsApplication: AccountsApplication;
|
}
|
||||||
|
|
||||||
// /**
|
/**
|
||||||
// * Retrieves the accounts data to exportable sheet.
|
* Retrieves the accounts data to exportable sheet.
|
||||||
// * @param {number} tenantId
|
*/
|
||||||
// * @returns
|
public exportable(query: IAccountsFilter) {
|
||||||
// */
|
const parsedQuery = {
|
||||||
// public exportable(tenantId: number, query: IAccountsFilter) {
|
sortOrder: 'desc',
|
||||||
// const parsedQuery = {
|
columnSortBy: 'created_at',
|
||||||
// sortOrder: 'desc',
|
inactiveMode: false,
|
||||||
// columnSortBy: 'created_at',
|
...query,
|
||||||
// inactiveMode: false,
|
structure: IAccountsStructureType.Flat,
|
||||||
// ...query,
|
pageSize: EXPORT_SIZE_LIMIT,
|
||||||
// structure: IAccountsStructureType.Flat,
|
page: 1,
|
||||||
// pageSize: EXPORT_SIZE_LIMIT,
|
} as IAccountsFilter;
|
||||||
// page: 1,
|
|
||||||
// } as IAccountsFilter;
|
|
||||||
|
|
||||||
// return this.accountsApplication
|
return this.accountsApplication
|
||||||
// .getAccounts(tenantId, parsedQuery)
|
.getAccounts(parsedQuery)
|
||||||
// .then((output) => output.accounts);
|
.then((output) => output.accounts);
|
||||||
// }
|
}
|
||||||
// }
|
}
|
||||||
|
|||||||
@@ -72,7 +72,10 @@ import { StripePaymentModule } from '../StripePayment/StripePayment.module';
|
|||||||
import { FeaturesModule } from '../Features/Features.module';
|
import { FeaturesModule } from '../Features/Features.module';
|
||||||
import { InventoryCostModule } from '../InventoryCost/InventoryCost.module';
|
import { InventoryCostModule } from '../InventoryCost/InventoryCost.module';
|
||||||
import { WarehousesTransfersModule } from '../WarehousesTransfers/WarehouseTransfers.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({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -180,6 +183,10 @@ import { WarehousesTransfersModule } from '../WarehousesTransfers/WarehouseTrans
|
|||||||
EventTrackerModule,
|
EventTrackerModule,
|
||||||
FinancialStatementsModule,
|
FinancialStatementsModule,
|
||||||
StripePaymentModule,
|
StripePaymentModule,
|
||||||
|
DashboardModule,
|
||||||
|
PaymentLinksModule,
|
||||||
|
RolesModule,
|
||||||
|
SubscriptionModule
|
||||||
],
|
],
|
||||||
controllers: [AppController],
|
controllers: [AppController],
|
||||||
providers: [
|
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 {string} reosurce
|
||||||
* @param {ExportFormat} format
|
* @param {ExportFormat} format
|
||||||
*/
|
*/
|
||||||
public export(tenantId: number, resource: string, format: ExportFormat) {
|
public export(resource: string, format: ExportFormat) {
|
||||||
return this.exportResource.export(tenantId, resource, format);
|
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 { Injectable } from '@nestjs/common';
|
||||||
|
import { ChromiumlyTenancy } from '../ChromiumlyTenancy/ChromiumlyTenancy.service';
|
||||||
|
import { TemplateInjectable } from '../TemplateInjectable/TemplateInjectable.service';
|
||||||
|
import { mapPdfRows } from './utils';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportPdf {
|
export class ExportPdf {
|
||||||
@@ -13,7 +12,6 @@ export class ExportPdf {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Generates the pdf table sheet for the given data and columns.
|
* Generates the pdf table sheet for the given data and columns.
|
||||||
* @param {number} tenantId
|
|
||||||
* @param {} columns
|
* @param {} columns
|
||||||
* @param {Record<string, string>} data
|
* @param {Record<string, string>} data
|
||||||
* @param {string} sheetTitle
|
* @param {string} sheetTitle
|
||||||
@@ -21,7 +19,6 @@ export class ExportPdf {
|
|||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
public async pdf(
|
public async pdf(
|
||||||
tenantId: number,
|
|
||||||
columns: { accessor: string },
|
columns: { accessor: string },
|
||||||
data: Record<string, any>,
|
data: Record<string, any>,
|
||||||
sheetTitle: string = '',
|
sheetTitle: string = '',
|
||||||
@@ -30,7 +27,6 @@ export class ExportPdf {
|
|||||||
const rows = mapPdfRows(columns, data);
|
const rows = mapPdfRows(columns, data);
|
||||||
|
|
||||||
const htmlContent = await this.templateInjectable.render(
|
const htmlContent = await this.templateInjectable.render(
|
||||||
tenantId,
|
|
||||||
'modules/export-resource-table',
|
'modules/export-resource-table',
|
||||||
{
|
{
|
||||||
table: { rows, columns },
|
table: { rows, columns },
|
||||||
@@ -39,7 +35,7 @@ export class ExportPdf {
|
|||||||
}
|
}
|
||||||
);
|
);
|
||||||
// Convert the HTML content to PDF
|
// 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 },
|
margins: { top: 0.2, bottom: 0.2, left: 0.2, right: 0.2 },
|
||||||
landscape: true,
|
landscape: true,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @ts-nocheck
|
|
||||||
// import Container, { Service } from 'typedi';
|
// import Container, { Service } from 'typedi';
|
||||||
// import { AccountsExportable } from '../Accounts/AccountsExportable';
|
// import { AccountsExportable } from '../Accounts/AccountsExportable';
|
||||||
// import { ExportableRegistry } from './ExportRegistery';
|
// import { ExportableRegistry } from './ExportRegistery';
|
||||||
@@ -20,10 +19,10 @@
|
|||||||
|
|
||||||
import { Injectable } from "@nestjs/common";
|
import { Injectable } from "@nestjs/common";
|
||||||
import { ExportableRegistry } from "./ExportRegistery";
|
import { ExportableRegistry } from "./ExportRegistery";
|
||||||
|
import { AccountsExportable } from "../Accounts/AccountsExportable.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ExportableResources {
|
export class ExportableResources {
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly exportRegistry: ExportableRegistry,
|
private readonly exportRegistry: ExportableRegistry,
|
||||||
) {
|
) {
|
||||||
@@ -34,7 +33,7 @@ export class ExportableResources {
|
|||||||
* Importable instances.
|
* Importable instances.
|
||||||
*/
|
*/
|
||||||
private importables = [
|
private importables = [
|
||||||
// { resource: 'Account', exportable: AccountsExportable },
|
{ resource: 'Account', exportable: AccountsExportable },
|
||||||
// { resource: 'Item', exportable: ItemsExportable },
|
// { resource: 'Item', exportable: ItemsExportable },
|
||||||
// { resource: 'ItemCategory', exportable: ItemCategoriesExportable },
|
// { resource: 'ItemCategory', exportable: ItemCategoriesExportable },
|
||||||
// { resource: 'Customer', exportable: CustomersExportable },
|
// { resource: 'Customer', exportable: CustomersExportable },
|
||||||
|
|||||||
@@ -23,29 +23,25 @@ export class ExportResourceService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
*
|
*
|
||||||
* @param {number} tenantId
|
|
||||||
* @param {string} resourceName
|
* @param {string} resourceName
|
||||||
* @param {ExportFormat} format
|
* @param {ExportFormat} format
|
||||||
* @returns
|
* @returns
|
||||||
*/
|
*/
|
||||||
public async export(
|
public async export(
|
||||||
tenantId: number,
|
|
||||||
resourceName: string,
|
resourceName: string,
|
||||||
format: ExportFormat = ExportFormat.Csv
|
format: ExportFormat = ExportFormat.Csv
|
||||||
) {
|
) {
|
||||||
return this.exportAls.run(() =>
|
return this.exportAls.run(() =>
|
||||||
this.exportAlsRun(tenantId, resourceName, format)
|
this.exportAlsRun(resourceName, format)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Exports the given resource data through csv, xlsx or pdf.
|
* Exports the given resource data through csv, xlsx or pdf.
|
||||||
* @param {number} tenantId - Tenant id.
|
|
||||||
* @param {string} resourceName - Resource name.
|
* @param {string} resourceName - Resource name.
|
||||||
* @param {ExportFormat} format - File format.
|
* @param {ExportFormat} format - File format.
|
||||||
*/
|
*/
|
||||||
public async exportAlsRun(
|
public async exportAlsRun(
|
||||||
tenantId: number,
|
|
||||||
resourceName: string,
|
resourceName: string,
|
||||||
format: ExportFormat = ExportFormat.Csv
|
format: ExportFormat = ExportFormat.Csv
|
||||||
) {
|
) {
|
||||||
@@ -85,8 +81,8 @@ export class ExportResourceService {
|
|||||||
* @param {string} resource - The name of the resource.
|
* @param {string} resource - The name of the resource.
|
||||||
* @returns The metadata of the resource.
|
* @returns The metadata of the resource.
|
||||||
*/
|
*/
|
||||||
private getResourceMeta(tenantId: number, resource: string) {
|
private getResourceMeta(resource: string) {
|
||||||
return this.resourceService.getResourceMeta(tenantId, resource);
|
return this.resourceService.getResourceMeta(resource);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,18 +100,15 @@ export class ExportResourceService {
|
|||||||
* Transforms the exported data based on the resource metadata.
|
* Transforms the exported data based on the resource metadata.
|
||||||
* If the resource metadata specifies a flattening attribute (`exportFlattenOn`),
|
* If the resource metadata specifies a flattening attribute (`exportFlattenOn`),
|
||||||
* the data will be flattened based on this attribute using the `flatDataCollections` utility function.
|
* 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 {string} resource - The name of the resource.
|
||||||
* @param {Array<Record<string, any>>} data - The original data to be transformed.
|
* @param {Array<Record<string, any>>} data - The original data to be transformed.
|
||||||
* @returns {Array<Record<string, any>>} - The transformed data.
|
* @returns {Array<Record<string, any>>} - The transformed data.
|
||||||
*/
|
*/
|
||||||
private transformExportedData(
|
private transformExportedData(
|
||||||
tenantId: number,
|
|
||||||
resource: string,
|
resource: string,
|
||||||
data: Array<Record<string, any>>
|
data: Array<Record<string, any>>
|
||||||
): 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>>>(
|
return R.when<Array<Record<string, any>>, Array<Record<string, any>>>(
|
||||||
R.always(Boolean(resourceMeta.exportFlattenOn)),
|
R.always(Boolean(resourceMeta.exportFlattenOn)),
|
||||||
@@ -129,11 +122,11 @@ export class ExportResourceService {
|
|||||||
* @param {string} resource - The name of the resource.
|
* @param {string} resource - The name of the resource.
|
||||||
* @returns A promise that resolves to the exportable data.
|
* @returns A promise that resolves to the exportable data.
|
||||||
*/
|
*/
|
||||||
private async getExportableData(tenantId: number, resource: string) {
|
private async getExportableData(resource: string) {
|
||||||
const exportable =
|
const exportable =
|
||||||
this.exportableResources.registry.getExportable(resource);
|
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.
|
* Retrieves the G/L sheet in xlsx format.
|
||||||
* @param {IGeneralLedgerSheetQuery} query
|
* @param {IGeneralLedgerSheetQuery} query
|
||||||
* @returns {}
|
* @returns {Promise<Buffer>}
|
||||||
*/
|
*/
|
||||||
public xlsx(
|
public xlsx(
|
||||||
query: IGeneralLedgerSheetQuery,
|
query: IGeneralLedgerSheetQuery,
|
||||||
@@ -50,6 +50,7 @@ export class GeneralLedgerApplication {
|
|||||||
/**
|
/**
|
||||||
* Retrieves the G/L sheet in csv format.
|
* Retrieves the G/L sheet in csv format.
|
||||||
* @param {IGeneralLedgerSheetQuery} query -
|
* @param {IGeneralLedgerSheetQuery} query -
|
||||||
|
* @returns {Promise<string>}
|
||||||
*/
|
*/
|
||||||
public csv(
|
public csv(
|
||||||
query: IGeneralLedgerSheetQuery,
|
query: IGeneralLedgerSheetQuery,
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import { DeleteItemCategoryService } from './commands/DeleteItemCategory.service
|
|||||||
import { EditItemCategoryService } from './commands/EditItemCategory.service';
|
import { EditItemCategoryService } from './commands/EditItemCategory.service';
|
||||||
import { GetItemCategoryService } from './queries/GetItemCategory.service';
|
import { GetItemCategoryService } from './queries/GetItemCategory.service';
|
||||||
import { GetItemCategoriesService } from './queries/GetItemCategories.service';
|
import { GetItemCategoriesService } from './queries/GetItemCategories.service';
|
||||||
|
import { CreateItemCategoryDto, EditItemCategoryDto } from './dtos/ItemCategory.dto';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class ItemCategoryApplication {
|
export class ItemCategoryApplication {
|
||||||
@@ -31,7 +32,7 @@ export class ItemCategoryApplication {
|
|||||||
* @returns {Promise<ItemCategory>} The created item category.
|
* @returns {Promise<ItemCategory>} The created item category.
|
||||||
*/
|
*/
|
||||||
public createItemCategory(
|
public createItemCategory(
|
||||||
itemCategoryDTO: IItemCategoryOTD,
|
itemCategoryDTO: CreateItemCategoryDto,
|
||||||
) {
|
) {
|
||||||
return this.createItemCategoryService.newItemCategory(itemCategoryDTO);
|
return this.createItemCategoryService.newItemCategory(itemCategoryDTO);
|
||||||
}
|
}
|
||||||
@@ -44,7 +45,7 @@ export class ItemCategoryApplication {
|
|||||||
*/
|
*/
|
||||||
public editItemCategory(
|
public editItemCategory(
|
||||||
itemCategoryId: number,
|
itemCategoryId: number,
|
||||||
itemCategoryDTO: IItemCategoryOTD,
|
itemCategoryDTO: EditItemCategoryDto,
|
||||||
) {
|
) {
|
||||||
return this.editItemCategoryService.editItemCategory(
|
return this.editItemCategoryService.editItemCategory(
|
||||||
itemCategoryId,
|
itemCategoryId,
|
||||||
|
|||||||
@@ -12,10 +12,13 @@ import { ItemCategoryApplication } from './ItemCategory.application';
|
|||||||
import {
|
import {
|
||||||
GetItemCategoriesResponse,
|
GetItemCategoriesResponse,
|
||||||
IItemCategoriesFilter,
|
IItemCategoriesFilter,
|
||||||
IItemCategoryOTD,
|
|
||||||
} from './ItemCategory.interfaces';
|
} from './ItemCategory.interfaces';
|
||||||
import { PublicRoute } from '../Auth/Jwt.guard';
|
import { PublicRoute } from '../Auth/Jwt.guard';
|
||||||
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
import { ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||||
|
import {
|
||||||
|
CreateItemCategoryDto,
|
||||||
|
EditItemCategoryDto,
|
||||||
|
} from './dtos/ItemCategory.dto';
|
||||||
|
|
||||||
@Controller('item-categories')
|
@Controller('item-categories')
|
||||||
@ApiTags('item-categories')
|
@ApiTags('item-categories')
|
||||||
@@ -27,7 +30,7 @@ export class ItemCategoryController {
|
|||||||
|
|
||||||
@Post()
|
@Post()
|
||||||
@ApiOperation({ summary: 'Create a new item category.' })
|
@ApiOperation({ summary: 'Create a new item category.' })
|
||||||
async createItemCategory(@Body() itemCategoryDTO: IItemCategoryOTD) {
|
async createItemCategory(@Body() itemCategoryDTO: CreateItemCategoryDto) {
|
||||||
return this.itemCategoryApplication.createItemCategory(itemCategoryDTO);
|
return this.itemCategoryApplication.createItemCategory(itemCategoryDTO);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -43,7 +46,7 @@ export class ItemCategoryController {
|
|||||||
@ApiOperation({ summary: 'Edit the given item category.' })
|
@ApiOperation({ summary: 'Edit the given item category.' })
|
||||||
async editItemCategory(
|
async editItemCategory(
|
||||||
@Param('id') id: number,
|
@Param('id') id: number,
|
||||||
@Body() itemCategoryDTO: IItemCategoryOTD,
|
@Body() itemCategoryDTO: EditItemCategoryDto,
|
||||||
) {
|
) {
|
||||||
return this.itemCategoryApplication.editItemCategory(id, itemCategoryDTO);
|
return this.itemCategoryApplication.editItemCategory(id, itemCategoryDTO);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,12 @@
|
|||||||
import { Injectable, Inject } from '@nestjs/common';
|
import { Injectable, Inject } from '@nestjs/common';
|
||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||||
import { Knex } from 'knex';
|
import { Knex } from 'knex';
|
||||||
import {
|
import { IItemCategoryCreatedPayload } from '../ItemCategory.interfaces';
|
||||||
IItemCategoryOTD,
|
|
||||||
IItemCategoryCreatedPayload,
|
|
||||||
} from '../ItemCategory.interfaces';
|
|
||||||
import { events } from '@/common/events/events';
|
import { events } from '@/common/events/events';
|
||||||
import { CommandItemCategoryValidatorService } from './CommandItemCategoryValidator.service';
|
import { CommandItemCategoryValidatorService } from './CommandItemCategoryValidator.service';
|
||||||
import { ItemCategory } from '../models/ItemCategory.model';
|
import { ItemCategory } from '../models/ItemCategory.model';
|
||||||
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
|
||||||
import { SystemUser } from '@/modules/System/models/SystemUser';
|
import { CreateItemCategoryDto } from '../dtos/ItemCategory.dto';
|
||||||
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class CreateItemCategoryService {
|
export class CreateItemCategoryService {
|
||||||
@@ -31,12 +27,11 @@ export class CreateItemCategoryService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Transforms OTD to model object.
|
* Transforms OTD to model object.
|
||||||
* @param {IItemCategoryOTD} itemCategoryOTD
|
* @param {CreateItemCategoryDto} itemCategoryOTD
|
||||||
* @param {ISystemUser} authorizedUser
|
* @returns {Partial<ItemCategory>}
|
||||||
* @returns {ItemCategory}
|
|
||||||
*/
|
*/
|
||||||
private transformOTDToObject(
|
private transformOTDToObject(
|
||||||
itemCategoryOTD: IItemCategoryOTD,
|
itemCategoryOTD: CreateItemCategoryDto,
|
||||||
): Partial<ItemCategory> {
|
): Partial<ItemCategory> {
|
||||||
return {
|
return {
|
||||||
...itemCategoryOTD,
|
...itemCategoryOTD,
|
||||||
@@ -47,10 +42,10 @@ export class CreateItemCategoryService {
|
|||||||
* Inserts a new item category.
|
* Inserts a new item category.
|
||||||
* @param {number} tenantId
|
* @param {number} tenantId
|
||||||
* @param {IItemCategoryOTD} itemCategoryOTD
|
* @param {IItemCategoryOTD} itemCategoryOTD
|
||||||
* @return {Promise<void>}
|
* @return {Promise<ItemCategory>}
|
||||||
*/
|
*/
|
||||||
public async newItemCategory(
|
public async newItemCategory(
|
||||||
itemCategoryOTD: IItemCategoryOTD,
|
itemCategoryOTD: CreateItemCategoryDto,
|
||||||
trx?: Knex.Transaction,
|
trx?: Knex.Transaction,
|
||||||
): Promise<ItemCategory> {
|
): Promise<ItemCategory> {
|
||||||
// Validate the category name uniquiness.
|
// Validate the category name uniquiness.
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { ItemCategory } from '../models/ItemCategory.model';
|
|||||||
import { Inject } from '@nestjs/common';
|
import { Inject } from '@nestjs/common';
|
||||||
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
|
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
|
||||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||||
|
import { EditItemCategoryDto } from '../dtos/ItemCategory.dto';
|
||||||
|
|
||||||
export class EditItemCategoryService {
|
export class EditItemCategoryService {
|
||||||
/**
|
/**
|
||||||
@@ -33,14 +34,13 @@ export class EditItemCategoryService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Edits item category.
|
* Edits item category.
|
||||||
* @param {number} tenantId
|
* @param {number} itemCategoryId - Item category id.
|
||||||
* @param {number} itemCategoryId
|
* @param {EditItemCategoryDto} itemCategoryOTD - Item category OTD.
|
||||||
* @param {IItemCategoryOTD} itemCategoryOTD
|
* @return {Promise<ItemCategory>}
|
||||||
* @return {Promise<void>}
|
|
||||||
*/
|
*/
|
||||||
public async editItemCategory(
|
public async editItemCategory(
|
||||||
itemCategoryId: number,
|
itemCategoryId: number,
|
||||||
itemCategoryOTD: IItemCategoryOTD,
|
itemCategoryOTD: EditItemCategoryDto,
|
||||||
): Promise<ItemCategory> {
|
): Promise<ItemCategory> {
|
||||||
// Retrieve the item category from the storage.
|
// Retrieve the item category from the storage.
|
||||||
const oldItemCategory = await this.itemCategoryModel()
|
const oldItemCategory = await this.itemCategoryModel()
|
||||||
@@ -90,11 +90,11 @@ export class EditItemCategoryService {
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Transforms OTD to model object.
|
* Transforms OTD to model object.
|
||||||
* @param {IItemCategoryOTD} itemCategoryOTD
|
* @param {EditItemCategoryDto} itemCategoryOTD
|
||||||
* @param {ISystemUser} authorizedUser
|
* @param {SystemUser} authorizedUser
|
||||||
*/
|
*/
|
||||||
private transformOTDToObject(
|
private transformOTDToObject(
|
||||||
itemCategoryOTD: IItemCategoryOTD,
|
itemCategoryOTD: EditItemCategoryDto,
|
||||||
authorizedUser: SystemUser,
|
authorizedUser: SystemUser,
|
||||||
) {
|
) {
|
||||||
return { ...itemCategoryOTD, userId: authorizedUser.id };
|
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';
|
import { Model } from 'objection';
|
||||||
|
|
||||||
export class PaymentLink extends Model {
|
export class PaymentLink extends SystemModel {
|
||||||
|
public id!: number;
|
||||||
public tenantId!: number;
|
public tenantId!: number;
|
||||||
public resourceId!: number;
|
public resourceId!: number;
|
||||||
public resourceType!: string;
|
public resourceType!: string;
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { BaseModel } from "@/models/Model";
|
import { BaseModel } from "@/models/Model";
|
||||||
|
|
||||||
export class PaymentIntegration extends BaseModel {
|
export class PaymentIntegration extends BaseModel {
|
||||||
paymentEnabled!: boolean;
|
readonly service!: string;
|
||||||
payoutEnabled!: boolean;
|
readonly paymentEnabled!: boolean;
|
||||||
|
readonly payoutEnabled!: boolean;
|
||||||
|
readonly accountId!: string;
|
||||||
|
|
||||||
static get tableName() {
|
static get tableName() {
|
||||||
return 'payment_integrations';
|
return 'payment_integrations';
|
||||||
@@ -24,6 +26,9 @@ export class PaymentIntegration extends BaseModel {
|
|||||||
return this.paymentEnabled && this.payoutEnabled;
|
return this.paymentEnabled && this.payoutEnabled;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
*
|
||||||
|
*/
|
||||||
static get modifiers() {
|
static get modifiers() {
|
||||||
return {
|
return {
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,7 +1,16 @@
|
|||||||
import { Model } from 'objection';
|
import { Model } from 'objection';
|
||||||
import { BaseModel } from '@/models/Model';
|
import { BaseModel } from '@/models/Model';
|
||||||
|
import { PaymentIntegration } from './PaymentIntegration.model';
|
||||||
|
|
||||||
export class TransactionPaymentServiceEntry extends BaseModel {
|
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
|
* 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 { ISearchRole } from '@/modules/DynamicListing/DynamicFilter/DynamicFilter.types';
|
||||||
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
|
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
|
||||||
import { PaymentIntegrationTransactionLink } from '../SaleInvoice.types';
|
import { PaymentIntegrationTransactionLink } from '../SaleInvoice.types';
|
||||||
|
import { TransactionPaymentServiceEntry } from '@/modules/PaymentServices/models/TransactionPaymentServiceEntry.model';
|
||||||
|
|
||||||
export class SaleInvoice extends TenantBaseModel{
|
export class SaleInvoice extends TenantBaseModel{
|
||||||
public taxAmountWithheld: number;
|
public taxAmountWithheld: number;
|
||||||
@@ -52,8 +53,7 @@ export class SaleInvoice extends TenantBaseModel{
|
|||||||
public entries!: ItemEntry[];
|
public entries!: ItemEntry[];
|
||||||
public attachments!: Document[];
|
public attachments!: Document[];
|
||||||
public writtenoffExpenseAccount!: Account;
|
public writtenoffExpenseAccount!: Account;
|
||||||
public paymentMethods!: PaymentIntegrationTransactionLink[];
|
public paymentMethods!: TransactionPaymentServiceEntry[];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Table name
|
* Table name
|
||||||
*/
|
*/
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { Inject, Injectable } from '@nestjs/common';
|
|||||||
import { events } from '@/common/events/events';
|
import { events } from '@/common/events/events';
|
||||||
import { PaymentIntegration } from '../models/PaymentIntegration.model';
|
import { PaymentIntegration } from '../models/PaymentIntegration.model';
|
||||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
||||||
|
import { TenantModel } from '@/modules/System/models/TenantModel';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class StripeWebhooksSubscriber {
|
export class StripeWebhooksSubscriber {
|
||||||
@@ -20,6 +21,9 @@ export class StripeWebhooksSubscriber {
|
|||||||
private readonly paymentIntegrationModel: TenantModelProxy<
|
private readonly paymentIntegrationModel: TenantModelProxy<
|
||||||
typeof PaymentIntegration
|
typeof PaymentIntegration
|
||||||
>,
|
>,
|
||||||
|
|
||||||
|
@Inject(TenantModel.name)
|
||||||
|
private readonly tenantModel: typeof TenantModel
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -34,6 +38,8 @@ export class StripeWebhooksSubscriber {
|
|||||||
const tenantId = parseInt(metadata.tenantId, 10);
|
const tenantId = parseInt(metadata.tenantId, 10);
|
||||||
const saleInvoiceId = parseInt(metadata.saleInvoiceId, 10);
|
const saleInvoiceId = parseInt(metadata.saleInvoiceId, 10);
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// await initalizeTenantServices(tenantId);
|
// await initalizeTenantServices(tenantId);
|
||||||
// await initializeTenantSettings(tenantId);
|
// await initializeTenantSettings(tenantId);
|
||||||
|
|
||||||
@@ -63,7 +69,7 @@ export class StripeWebhooksSubscriber {
|
|||||||
if (!metadata?.paymentIntegrationId || !metadata.tenantId) return;
|
if (!metadata?.paymentIntegrationId || !metadata.tenantId) return;
|
||||||
|
|
||||||
// Find the tenant or throw not found error.
|
// 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
|
// Check if the account capabilities are active
|
||||||
if (account.capabilities.card_payments === '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';
|
} from '@nestjs/common';
|
||||||
import { PlanSubscription } from '../models/PlanSubscription';
|
import { PlanSubscription } from '../models/PlanSubscription';
|
||||||
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
|
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
|
||||||
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
|
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SubscriptionGuard implements CanActivate {
|
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 canceledAt: Date;
|
||||||
public readonly trialEndsAt: Date;
|
public readonly trialEndsAt: Date;
|
||||||
public readonly paymentStatus: SubscriptionPaymentStatus;
|
public readonly paymentStatus: SubscriptionPaymentStatus;
|
||||||
|
public readonly planId: number;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Table name.
|
* 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 { PaymentReceived } from '@/modules/PaymentReceived/models/PaymentReceived';
|
||||||
import { Model } from 'objection';
|
import { Model } from 'objection';
|
||||||
import { ClsModule } from 'nestjs-cls';
|
import { ClsModule } from 'nestjs-cls';
|
||||||
|
import { TenantUser } from './models/TenantUser.model';
|
||||||
|
|
||||||
const models = [
|
const models = [
|
||||||
Item,
|
Item,
|
||||||
@@ -78,6 +79,7 @@ const models = [
|
|||||||
RefundVendorCredit,
|
RefundVendorCredit,
|
||||||
PaymentReceived,
|
PaymentReceived,
|
||||||
PaymentReceivedEntry,
|
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':
|
'@bigcapital/utils':
|
||||||
specifier: '*'
|
specifier: '*'
|
||||||
version: link:../../shared/bigcapital-utils
|
version: link:../../shared/bigcapital-utils
|
||||||
|
'@lemonsqueezy/lemonsqueezy.js':
|
||||||
|
specifier: ^2.2.0
|
||||||
|
version: 2.2.0
|
||||||
'@liaoliaots/nestjs-redis':
|
'@liaoliaots/nestjs-redis':
|
||||||
specifier: ^10.0.0
|
specifier: ^10.0.0
|
||||||
version: 10.0.0(@nestjs/common@10.4.7)(@nestjs/core@10.4.7)(ioredis@5.6.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