refactor: subscriptions to nestjs

This commit is contained in:
Ahmed Bouhuolia
2025-03-24 23:38:43 +02:00
parent 4c42515613
commit ef22b9ddaf
87 changed files with 3949 additions and 92 deletions

3
.gitignore vendored
View File

@@ -6,4 +6,5 @@ node_modules/
# Production env file
.env
test-results/
test-results/
.qodo

View File

@@ -41,6 +41,7 @@
"@types/nodemailer": "^6.4.17",
"@types/passport-local": "^1.0.38",
"@types/ramda": "^0.30.2",
"@lemonsqueezy/lemonsqueezy.js": "^2.2.0",
"accounting": "^0.4.1",
"async": "^3.2.0",
"async-mutex": "^0.5.0",

View File

@@ -1,32 +1,29 @@
// import { Inject, Service } from 'typedi';
// import { AccountsApplication } from './AccountsApplication.service';
// import { Exportable } from '../Export/Exportable';
// import { IAccountsFilter, IAccountsStructureType } from '@/interfaces';
// import { EXPORT_SIZE_LIMIT } from '../Export/constants';
import { AccountsApplication } from './AccountsApplication.service';
import { Exportable } from '../Export/Exportable';
import { EXPORT_SIZE_LIMIT } from '../Export/constants';
import { IAccountsFilter, IAccountsStructureType } from './Accounts.types';
// @Service()
// export class AccountsExportable extends Exportable {
// @Inject()
// private accountsApplication: AccountsApplication;
export class AccountsExportable extends Exportable {
constructor(private readonly accountsApplication: AccountsApplication) {
super();
}
// /**
// * Retrieves the accounts data to exportable sheet.
// * @param {number} tenantId
// * @returns
// */
// public exportable(tenantId: number, query: IAccountsFilter) {
// const parsedQuery = {
// sortOrder: 'desc',
// columnSortBy: 'created_at',
// inactiveMode: false,
// ...query,
// structure: IAccountsStructureType.Flat,
// pageSize: EXPORT_SIZE_LIMIT,
// page: 1,
// } as IAccountsFilter;
/**
* Retrieves the accounts data to exportable sheet.
*/
public exportable(query: IAccountsFilter) {
const parsedQuery = {
sortOrder: 'desc',
columnSortBy: 'created_at',
inactiveMode: false,
...query,
structure: IAccountsStructureType.Flat,
pageSize: EXPORT_SIZE_LIMIT,
page: 1,
} as IAccountsFilter;
// return this.accountsApplication
// .getAccounts(tenantId, parsedQuery)
// .then((output) => output.accounts);
// }
// }
return this.accountsApplication
.getAccounts(parsedQuery)
.then((output) => output.accounts);
}
}

View File

@@ -72,7 +72,10 @@ import { StripePaymentModule } from '../StripePayment/StripePayment.module';
import { FeaturesModule } from '../Features/Features.module';
import { InventoryCostModule } from '../InventoryCost/InventoryCost.module';
import { WarehousesTransfersModule } from '../WarehousesTransfers/WarehouseTransfers.module';
import { DashboardModule } from '../Dashboard/Dashboard.module';
import { PaymentLinksModule } from '../PaymentLinks/PaymentLinks.module';
import { RolesModule } from '../Roles/Roles.module';
import { SubscriptionModule } from '../Subscription/Subscription.module';
@Module({
imports: [
@@ -180,6 +183,10 @@ import { WarehousesTransfersModule } from '../WarehousesTransfers/WarehouseTrans
EventTrackerModule,
FinancialStatementsModule,
StripePaymentModule,
DashboardModule,
PaymentLinksModule,
RolesModule,
SubscriptionModule
],
controllers: [AppController],
providers: [

View File

@@ -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();
}
}

View File

@@ -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 {}

View File

@@ -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);
};
}

View 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);
}
}
}

View 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 {}

View File

@@ -16,7 +16,7 @@ export class ExportApplication {
* @param {string} reosurce
* @param {ExportFormat} format
*/
public export(tenantId: number, resource: string, format: ExportFormat) {
return this.exportResource.export(tenantId, resource, format);
public export(resource: string, format: ExportFormat) {
return this.exportResource.export(resource, format);
}
}

View File

@@ -1,8 +1,7 @@
// @ts-nocheck
// import { ChromiumlyTenancy } from '../ChromiumlyTenancy/ChromiumlyTenancy';
// import { TemplateInjectable } from '../TemplateInjectable/TemplateInjectable';
import { mapPdfRows } from './utils';
import { Injectable } from '@nestjs/common';
import { ChromiumlyTenancy } from '../ChromiumlyTenancy/ChromiumlyTenancy.service';
import { TemplateInjectable } from '../TemplateInjectable/TemplateInjectable.service';
import { mapPdfRows } from './utils';
@Injectable()
export class ExportPdf {
@@ -13,7 +12,6 @@ export class ExportPdf {
/**
* Generates the pdf table sheet for the given data and columns.
* @param {number} tenantId
* @param {} columns
* @param {Record<string, string>} data
* @param {string} sheetTitle
@@ -21,7 +19,6 @@ export class ExportPdf {
* @returns
*/
public async pdf(
tenantId: number,
columns: { accessor: string },
data: Record<string, any>,
sheetTitle: string = '',
@@ -30,7 +27,6 @@ export class ExportPdf {
const rows = mapPdfRows(columns, data);
const htmlContent = await this.templateInjectable.render(
tenantId,
'modules/export-resource-table',
{
table: { rows, columns },
@@ -39,7 +35,7 @@ export class ExportPdf {
}
);
// Convert the HTML content to PDF
return this.chromiumlyTenancy.convertHtmlContent(tenantId, htmlContent, {
return this.chromiumlyTenancy.convertHtmlContent(htmlContent, {
margins: { top: 0.2, bottom: 0.2, left: 0.2, right: 0.2 },
landscape: true,
});

View File

@@ -1,4 +1,3 @@
// @ts-nocheck
// import Container, { Service } from 'typedi';
// import { AccountsExportable } from '../Accounts/AccountsExportable';
// import { ExportableRegistry } from './ExportRegistery';
@@ -20,10 +19,10 @@
import { Injectable } from "@nestjs/common";
import { ExportableRegistry } from "./ExportRegistery";
import { AccountsExportable } from "../Accounts/AccountsExportable.service";
@Injectable()
export class ExportableResources {
constructor(
private readonly exportRegistry: ExportableRegistry,
) {
@@ -34,7 +33,7 @@ export class ExportableResources {
* Importable instances.
*/
private importables = [
// { resource: 'Account', exportable: AccountsExportable },
{ resource: 'Account', exportable: AccountsExportable },
// { resource: 'Item', exportable: ItemsExportable },
// { resource: 'ItemCategory', exportable: ItemCategoriesExportable },
// { resource: 'Customer', exportable: CustomersExportable },

View File

@@ -23,29 +23,25 @@ export class ExportResourceService {
/**
*
* @param {number} tenantId
* @param {string} resourceName
* @param {ExportFormat} format
* @returns
*/
public async export(
tenantId: number,
resourceName: string,
format: ExportFormat = ExportFormat.Csv
) {
return this.exportAls.run(() =>
this.exportAlsRun(tenantId, resourceName, format)
this.exportAlsRun(resourceName, format)
);
}
/**
* Exports the given resource data through csv, xlsx or pdf.
* @param {number} tenantId - Tenant id.
* @param {string} resourceName - Resource name.
* @param {ExportFormat} format - File format.
*/
public async exportAlsRun(
tenantId: number,
resourceName: string,
format: ExportFormat = ExportFormat.Csv
) {
@@ -85,8 +81,8 @@ export class ExportResourceService {
* @param {string} resource - The name of the resource.
* @returns The metadata of the resource.
*/
private getResourceMeta(tenantId: number, resource: string) {
return this.resourceService.getResourceMeta(tenantId, resource);
private getResourceMeta(resource: string) {
return this.resourceService.getResourceMeta(resource);
}
/**
@@ -104,18 +100,15 @@ export class ExportResourceService {
* Transforms the exported data based on the resource metadata.
* If the resource metadata specifies a flattening attribute (`exportFlattenOn`),
* the data will be flattened based on this attribute using the `flatDataCollections` utility function.
*
* @param {number} tenantId - The tenant identifier.
* @param {string} resource - The name of the resource.
* @param {Array<Record<string, any>>} data - The original data to be transformed.
* @returns {Array<Record<string, any>>} - The transformed data.
*/
private transformExportedData(
tenantId: number,
resource: string,
data: Array<Record<string, any>>
): Array<Record<string, any>> {
const resourceMeta = this.getResourceMeta(tenantId, resource);
const resourceMeta = this.getResourceMeta(resource);
return R.when<Array<Record<string, any>>, Array<Record<string, any>>>(
R.always(Boolean(resourceMeta.exportFlattenOn)),
@@ -129,11 +122,11 @@ export class ExportResourceService {
* @param {string} resource - The name of the resource.
* @returns A promise that resolves to the exportable data.
*/
private async getExportableData(tenantId: number, resource: string) {
private async getExportableData(resource: string) {
const exportable =
this.exportableResources.registry.getExportable(resource);
return exportable.exportable(tenantId, {});
return exportable.exportable({});
}
/**

View File

@@ -0,0 +1,11 @@
import { IsNotEmpty, IsString } from 'class-validator';
export class ExportQuery {
@IsString()
@IsNotEmpty()
resource: string;
@IsString()
@IsNotEmpty()
format: string;
}

View File

@@ -39,7 +39,7 @@ export class GeneralLedgerApplication {
/**
* Retrieves the G/L sheet in xlsx format.
* @param {IGeneralLedgerSheetQuery} query
* @returns {}
* @returns {Promise<Buffer>}
*/
public xlsx(
query: IGeneralLedgerSheetQuery,
@@ -50,6 +50,7 @@ export class GeneralLedgerApplication {
/**
* Retrieves the G/L sheet in csv format.
* @param {IGeneralLedgerSheetQuery} query -
* @returns {Promise<string>}
*/
public csv(
query: IGeneralLedgerSheetQuery,

View File

@@ -8,6 +8,7 @@ import { DeleteItemCategoryService } from './commands/DeleteItemCategory.service
import { EditItemCategoryService } from './commands/EditItemCategory.service';
import { GetItemCategoryService } from './queries/GetItemCategory.service';
import { GetItemCategoriesService } from './queries/GetItemCategories.service';
import { CreateItemCategoryDto, EditItemCategoryDto } from './dtos/ItemCategory.dto';
@Injectable()
export class ItemCategoryApplication {
@@ -31,7 +32,7 @@ export class ItemCategoryApplication {
* @returns {Promise<ItemCategory>} The created item category.
*/
public createItemCategory(
itemCategoryDTO: IItemCategoryOTD,
itemCategoryDTO: CreateItemCategoryDto,
) {
return this.createItemCategoryService.newItemCategory(itemCategoryDTO);
}
@@ -44,7 +45,7 @@ export class ItemCategoryApplication {
*/
public editItemCategory(
itemCategoryId: number,
itemCategoryDTO: IItemCategoryOTD,
itemCategoryDTO: EditItemCategoryDto,
) {
return this.editItemCategoryService.editItemCategory(
itemCategoryId,

View File

@@ -12,10 +12,13 @@ import { ItemCategoryApplication } from './ItemCategory.application';
import {
GetItemCategoriesResponse,
IItemCategoriesFilter,
IItemCategoryOTD,
} from './ItemCategory.interfaces';
import { PublicRoute } from '../Auth/Jwt.guard';
import { ApiOperation, ApiTags } from '@nestjs/swagger';
import {
CreateItemCategoryDto,
EditItemCategoryDto,
} from './dtos/ItemCategory.dto';
@Controller('item-categories')
@ApiTags('item-categories')
@@ -27,7 +30,7 @@ export class ItemCategoryController {
@Post()
@ApiOperation({ summary: 'Create a new item category.' })
async createItemCategory(@Body() itemCategoryDTO: IItemCategoryOTD) {
async createItemCategory(@Body() itemCategoryDTO: CreateItemCategoryDto) {
return this.itemCategoryApplication.createItemCategory(itemCategoryDTO);
}
@@ -43,7 +46,7 @@ export class ItemCategoryController {
@ApiOperation({ summary: 'Edit the given item category.' })
async editItemCategory(
@Param('id') id: number,
@Body() itemCategoryDTO: IItemCategoryOTD,
@Body() itemCategoryDTO: EditItemCategoryDto,
) {
return this.itemCategoryApplication.editItemCategory(id, itemCategoryDTO);
}

View File

@@ -1,16 +1,12 @@
import { Injectable, Inject } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Knex } from 'knex';
import {
IItemCategoryOTD,
IItemCategoryCreatedPayload,
} from '../ItemCategory.interfaces';
import { IItemCategoryCreatedPayload } from '../ItemCategory.interfaces';
import { events } from '@/common/events/events';
import { CommandItemCategoryValidatorService } from './CommandItemCategoryValidator.service';
import { ItemCategory } from '../models/ItemCategory.model';
import { UnitOfWork } from '@/modules/Tenancy/TenancyDB/UnitOfWork.service';
import { SystemUser } from '@/modules/System/models/SystemUser';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import { CreateItemCategoryDto } from '../dtos/ItemCategory.dto';
@Injectable()
export class CreateItemCategoryService {
@@ -31,12 +27,11 @@ export class CreateItemCategoryService {
/**
* Transforms OTD to model object.
* @param {IItemCategoryOTD} itemCategoryOTD
* @param {ISystemUser} authorizedUser
* @returns {ItemCategory}
* @param {CreateItemCategoryDto} itemCategoryOTD
* @returns {Partial<ItemCategory>}
*/
private transformOTDToObject(
itemCategoryOTD: IItemCategoryOTD,
itemCategoryOTD: CreateItemCategoryDto,
): Partial<ItemCategory> {
return {
...itemCategoryOTD,
@@ -47,10 +42,10 @@ export class CreateItemCategoryService {
* Inserts a new item category.
* @param {number} tenantId
* @param {IItemCategoryOTD} itemCategoryOTD
* @return {Promise<void>}
* @return {Promise<ItemCategory>}
*/
public async newItemCategory(
itemCategoryOTD: IItemCategoryOTD,
itemCategoryOTD: CreateItemCategoryDto,
trx?: Knex.Transaction,
): Promise<ItemCategory> {
// Validate the category name uniquiness.

View File

@@ -12,6 +12,7 @@ import { ItemCategory } from '../models/ItemCategory.model';
import { Inject } from '@nestjs/common';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { EditItemCategoryDto } from '../dtos/ItemCategory.dto';
export class EditItemCategoryService {
/**
@@ -33,14 +34,13 @@ export class EditItemCategoryService {
/**
* Edits item category.
* @param {number} tenantId
* @param {number} itemCategoryId
* @param {IItemCategoryOTD} itemCategoryOTD
* @return {Promise<void>}
* @param {number} itemCategoryId - Item category id.
* @param {EditItemCategoryDto} itemCategoryOTD - Item category OTD.
* @return {Promise<ItemCategory>}
*/
public async editItemCategory(
itemCategoryId: number,
itemCategoryOTD: IItemCategoryOTD,
itemCategoryOTD: EditItemCategoryDto,
): Promise<ItemCategory> {
// Retrieve the item category from the storage.
const oldItemCategory = await this.itemCategoryModel()
@@ -90,11 +90,11 @@ export class EditItemCategoryService {
/**
* Transforms OTD to model object.
* @param {IItemCategoryOTD} itemCategoryOTD
* @param {ISystemUser} authorizedUser
* @param {EditItemCategoryDto} itemCategoryOTD
* @param {SystemUser} authorizedUser
*/
private transformOTDToObject(
itemCategoryOTD: IItemCategoryOTD,
itemCategoryOTD: EditItemCategoryDto,
authorizedUser: SystemUser,
) {
return { ...itemCategoryOTD, userId: authorizedUser.id };

View File

@@ -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 {}

View File

@@ -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);
};
}

View File

@@ -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,
});
}
}

View File

@@ -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);
}
}
}

View File

@@ -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'
};

View File

@@ -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 },
);
}
}

View File

@@ -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(),
);
}
}

View File

@@ -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);
}
}

View File

@@ -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);
}
}

View File

@@ -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 {}

View File

@@ -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,
);
}
}

View File

@@ -1,6 +1,8 @@
import { SystemModel } from '@/modules/System/models/SystemModel';
import { Model } from 'objection';
export class PaymentLink extends Model {
export class PaymentLink extends SystemModel {
public id!: number;
public tenantId!: number;
public resourceId!: number;
public resourceType!: string;

View File

@@ -1,8 +1,10 @@
import { BaseModel } from "@/models/Model";
export class PaymentIntegration extends BaseModel {
paymentEnabled!: boolean;
payoutEnabled!: boolean;
readonly service!: string;
readonly paymentEnabled!: boolean;
readonly payoutEnabled!: boolean;
readonly accountId!: string;
static get tableName() {
return 'payment_integrations';
@@ -24,6 +26,9 @@ export class PaymentIntegration extends BaseModel {
return this.paymentEnabled && this.payoutEnabled;
}
/**
*
*/
static get modifiers() {
return {
/**

View File

@@ -1,7 +1,16 @@
import { Model } from 'objection';
import { BaseModel } from '@/models/Model';
import { PaymentIntegration } from './PaymentIntegration.model';
export class TransactionPaymentServiceEntry extends BaseModel {
readonly referenceId!: number;
readonly referenceType!: string;
readonly paymentIntegrationId!: number;
readonly enable!: boolean;
readonly options!: Record<string, any>;
readonly paymentIntegration: PaymentIntegration;
/**
* Table name
*/

View 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);
};

View File

@@ -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);
}
}

View 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();
}
}

View 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 });
}
}

View 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 {}

View 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;
}

View 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,
};
});
}

View File

@@ -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;
});
}
}

View File

@@ -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);
});
}
}

View File

@@ -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;
});
}
}

View 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'
};

View 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 {}

View 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',
},
},
};
}
}

View File

@@ -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',
},
},
};
}
}

View File

@@ -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,
});
}
};
}

View File

@@ -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());
};
}

View File

@@ -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;
}
}

View File

@@ -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;
}
}

View 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;
});
}

View File

@@ -12,6 +12,7 @@ import { Account } from '@/modules/Accounts/models/Account.model';
import { ISearchRole } from '@/modules/DynamicListing/DynamicFilter/DynamicFilter.types';
import { TenantBaseModel } from '@/modules/System/models/TenantBaseModel';
import { PaymentIntegrationTransactionLink } from '../SaleInvoice.types';
import { TransactionPaymentServiceEntry } from '@/modules/PaymentServices/models/TransactionPaymentServiceEntry.model';
export class SaleInvoice extends TenantBaseModel{
public taxAmountWithheld: number;
@@ -52,8 +53,7 @@ export class SaleInvoice extends TenantBaseModel{
public entries!: ItemEntry[];
public attachments!: Document[];
public writtenoffExpenseAccount!: Account;
public paymentMethods!: PaymentIntegrationTransactionLink[];
public paymentMethods!: TransactionPaymentServiceEntry[];
/**
* Table name
*/

View File

@@ -10,6 +10,7 @@ import { Inject, Injectable } from '@nestjs/common';
import { events } from '@/common/events/events';
import { PaymentIntegration } from '../models/PaymentIntegration.model';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
import { TenantModel } from '@/modules/System/models/TenantModel';
@Injectable()
export class StripeWebhooksSubscriber {
@@ -20,6 +21,9 @@ export class StripeWebhooksSubscriber {
private readonly paymentIntegrationModel: TenantModelProxy<
typeof PaymentIntegration
>,
@Inject(TenantModel.name)
private readonly tenantModel: typeof TenantModel
) {}
/**
@@ -34,6 +38,8 @@ export class StripeWebhooksSubscriber {
const tenantId = parseInt(metadata.tenantId, 10);
const saleInvoiceId = parseInt(metadata.saleInvoiceId, 10);
// await initalizeTenantServices(tenantId);
// await initializeTenantSettings(tenantId);
@@ -63,7 +69,7 @@ export class StripeWebhooksSubscriber {
if (!metadata?.paymentIntegrationId || !metadata.tenantId) return;
// Find the tenant or throw not found error.
// await Tenant.query().findById(tenantId).throwIfNotFound();
await this.tenantModel.query().findById(tenantId).throwIfNotFound();
// Check if the account capabilities are active
if (account.capabilities.card_payments === 'active') {

View File

@@ -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 {}

View File

@@ -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);
}
}

View File

@@ -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.',
});
}
}

View File

@@ -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);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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,
);
}
}

View File

@@ -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,
},
);
}
}

View File

@@ -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 },
);
}
}

View File

@@ -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,
},
);
}
}

View File

@@ -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,
},
);
}
}

View File

@@ -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 },
);
}
}

View File

@@ -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,
);
}
}
}

View File

@@ -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,
);
}
}

View File

@@ -7,7 +7,6 @@ import {
} from '@nestjs/common';
import { PlanSubscription } from '../models/PlanSubscription';
import { TenancyContext } from '@/modules/Tenancy/TenancyContext.service';
import { TenantModelProxy } from '@/modules/System/models/TenantBaseModel';
@Injectable()
export class SubscriptionGuard implements CanActivate {

View 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;
}
}

View File

@@ -11,6 +11,7 @@ export class PlanSubscription extends mixin(SystemModel) {
public readonly canceledAt: Date;
public readonly trialEndsAt: Date;
public readonly paymentStatus: SubscriptionPaymentStatus;
public readonly planId: number;
/**
* Table name.

View File

@@ -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!',
},
},
);
}
}

View File

@@ -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,
},
);
}
}

View File

@@ -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;
};
}

View File

@@ -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');
}
}

View File

@@ -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' });
}
}

View 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;
}

View 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);
}

View File

@@ -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 */
}
}
}
}

View File

@@ -39,6 +39,7 @@ import { RefundVendorCredit } from '@/modules/VendorCreditsRefund/models/RefundV
import { PaymentReceived } from '@/modules/PaymentReceived/models/PaymentReceived';
import { Model } from 'objection';
import { ClsModule } from 'nestjs-cls';
import { TenantUser } from './models/TenantUser.model';
const models = [
Item,
@@ -78,6 +79,7 @@ const models = [
RefundVendorCredit,
PaymentReceived,
PaymentReceivedEntry,
TenantUser,
];
/**

View File

@@ -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
View File

@@ -496,6 +496,9 @@ importers:
'@bigcapital/utils':
specifier: '*'
version: link:../../shared/bigcapital-utils
'@lemonsqueezy/lemonsqueezy.js':
specifier: ^2.2.0
version: 2.2.0
'@liaoliaots/nestjs-redis':
specifier: ^10.0.0
version: 10.0.0(@nestjs/common@10.4.7)(@nestjs/core@10.4.7)(ioredis@5.6.0)