diff --git a/src/app.module.ts b/src/app.module.ts index ca0c098..9b8b40c 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -12,6 +12,7 @@ import { SentMessagesModule } from './sent-messages/sent-messages.module'; import { SmsTemplatesModule } from './sms-templates/sms-templates.module'; import { SuggestionsModule } from './suggestions/suggestions.module'; import { StylistsModule } from './stylists/stylists.module'; +import { SubscriptionsModule } from './subscriptions/subscriptions.module'; import { UsersModule } from './users/users.module'; @Module({ @@ -31,6 +32,7 @@ import { UsersModule } from './users/users.module'; SuggestionsModule, SmsTemplatesModule, SentMessagesModule, + SubscriptionsModule, ], controllers: [], providers: [], diff --git a/src/auth/auth.controller.ts b/src/auth/auth.controller.ts index b84aff7..71d742d 100644 --- a/src/auth/auth.controller.ts +++ b/src/auth/auth.controller.ts @@ -1,4 +1,5 @@ import { Body, Controller, Post } from '@nestjs/common'; +import { AllowWithoutSubscription } from '../auth/decorators/allow-without-subscription.decorator'; import { AuthService } from './auth.service'; import { CurrentUser } from './decorators/current-user.decorator'; import { Public } from './decorators/public.decorator'; @@ -29,6 +30,7 @@ export class AuthController { return this.authService.refresh(dto.refreshToken); } + @AllowWithoutSubscription() @Post('logout') logout(@CurrentUser() user: AuthUser, @Body() dto: RefreshTokenDto) { return this.authService.logout(user.id, dto.refreshToken); diff --git a/src/auth/decorators/allow-without-subscription.decorator.ts b/src/auth/decorators/allow-without-subscription.decorator.ts new file mode 100644 index 0000000..68a6203 --- /dev/null +++ b/src/auth/decorators/allow-without-subscription.decorator.ts @@ -0,0 +1,6 @@ +import { SetMetadata } from '@nestjs/common'; + +export const ALLOW_WITHOUT_SUBSCRIPTION_KEY = 'allowWithoutSubscription'; + +export const AllowWithoutSubscription = () => + SetMetadata(ALLOW_WITHOUT_SUBSCRIPTION_KEY, true); diff --git a/src/authorization/authorization.module.ts b/src/authorization/authorization.module.ts index 55952d2..f9526c8 100644 --- a/src/authorization/authorization.module.ts +++ b/src/authorization/authorization.module.ts @@ -9,6 +9,7 @@ import { ReservePolicy } from './policies/reserve.policy'; import { SalonPolicy } from './policies/salon.policy'; import { ServicePolicy } from './policies/service.policy'; import { StylistPolicy } from './policies/stylist.policy'; +import { SubscriptionPolicy } from './policies/subscription.policy'; @Module({ imports: [TypeOrmModule.forFeature([Permission, UserPermission, Reserve])], @@ -19,6 +20,7 @@ import { StylistPolicy } from './policies/stylist.policy'; CustomerPolicy, StylistPolicy, ReservePolicy, + SubscriptionPolicy, ], exports: [ AuthorizationService, @@ -27,6 +29,7 @@ import { StylistPolicy } from './policies/stylist.policy'; CustomerPolicy, StylistPolicy, ReservePolicy, + SubscriptionPolicy, ], }) export class AuthorizationModule {} diff --git a/src/authorization/enums/permission-code.enum.ts b/src/authorization/enums/permission-code.enum.ts index c27aa85..fd2efbc 100644 --- a/src/authorization/enums/permission-code.enum.ts +++ b/src/authorization/enums/permission-code.enum.ts @@ -18,4 +18,6 @@ export enum PermissionCode { SERVICES_DELETE = 'services:delete', RESERVES_READ = 'reserves:read', RESERVES_WRITE = 'reserves:write', + SUBSCRIPTIONS_READ = 'subscriptions:read', + SUBSCRIPTIONS_WRITE = 'subscriptions:write', } diff --git a/src/authorization/policies/subscription.policy.ts b/src/authorization/policies/subscription.policy.ts new file mode 100644 index 0000000..acacbfa --- /dev/null +++ b/src/authorization/policies/subscription.policy.ts @@ -0,0 +1,30 @@ +import { Injectable } from '@nestjs/common'; +import { AuthUser } from '../../auth/interfaces/auth-user.interface'; +import { Salon } from '../../salons/entities/salon.entity'; +import { UserRole } from '../../users/enums/user-role.enum'; +import { AuthorizationService } from '../authorization.service'; + +@Injectable() +export class SubscriptionPolicy { + constructor(private readonly authorizationService: AuthorizationService) {} + + canRead(user: AuthUser, salon: Salon): boolean { + if (this.authorizationService.isAdmin(user)) { + return true; + } + if (user.role === UserRole.SALON) { + return salon.ownerId === user.id; + } + return false; + } + + canPurchase(user: AuthUser, salon: Salon): boolean { + if (this.authorizationService.isAdmin(user)) { + return true; + } + if (user.role === UserRole.SALON) { + return salon.ownerId === user.id; + } + return false; + } +} diff --git a/src/database/migrations/1720400000000-AddSubscriptions.ts b/src/database/migrations/1720400000000-AddSubscriptions.ts new file mode 100644 index 0000000..2bccb82 --- /dev/null +++ b/src/database/migrations/1720400000000-AddSubscriptions.ts @@ -0,0 +1,191 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, +} from 'typeorm'; + +export class AddSubscriptions1720400000000 implements MigrationInterface { + name = 'AddSubscriptions1720400000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `CREATE TYPE "public"."salon_subscriptions_status_enum" AS ENUM('active', 'expired', 'cancelled')`, + ); + + await queryRunner.createTable( + new Table({ + name: 'subscription_plans', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'name', type: 'varchar', length: '100' }, + { name: 'code', type: 'varchar', length: '50', isUnique: true }, + { name: 'durationMonths', type: 'int' }, + { name: 'price', type: 'bigint' }, + { name: 'freeSmsCount', type: 'int' }, + { name: 'isActive', type: 'boolean', default: true }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + name: 'sms_packages', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'name', type: 'varchar', length: '100' }, + { name: 'code', type: 'varchar', length: '50', isUnique: true }, + { name: 'smsCount', type: 'int' }, + { name: 'price', type: 'bigint' }, + { name: 'isActive', type: 'boolean', default: true }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + name: 'salon_subscriptions', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'salonId', type: 'uuid' }, + { name: 'planId', type: 'uuid' }, + { + name: 'status', + type: 'salon_subscriptions_status_enum', + default: `'active'`, + }, + { name: 'startDate', type: 'timestamp' }, + { name: 'endDate', type: 'timestamp' }, + { name: 'pricePaid', type: 'bigint' }, + { name: 'freeSmsGranted', type: 'int' }, + { name: 'freeSmsRemaining', type: 'int' }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createTable( + new Table({ + name: 'salon_sms_purchases', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'salonId', type: 'uuid' }, + { name: 'packageId', type: 'uuid' }, + { name: 'smsCount', type: 'int' }, + { name: 'smsRemaining', type: 'int' }, + { name: 'pricePaid', type: 'bigint' }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'salon_subscriptions', + new TableForeignKey({ + columnNames: ['salonId'], + referencedTableName: 'salons', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'salon_subscriptions', + new TableForeignKey({ + columnNames: ['planId'], + referencedTableName: 'subscription_plans', + referencedColumnNames: ['id'], + onDelete: 'RESTRICT', + }), + ); + + await queryRunner.createForeignKey( + 'salon_sms_purchases', + new TableForeignKey({ + columnNames: ['salonId'], + referencedTableName: 'salons', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'salon_sms_purchases', + new TableForeignKey({ + columnNames: ['packageId'], + referencedTableName: 'sms_packages', + referencedColumnNames: ['id'], + onDelete: 'RESTRICT', + }), + ); + + await queryRunner.createIndex( + 'salon_subscriptions', + new TableIndex({ + name: 'IDX_salon_subscriptions_salonId_status', + columnNames: ['salonId', 'status'], + }), + ); + + await queryRunner.query(` + INSERT INTO subscription_plans (name, code, "durationMonths", price, "freeSmsCount") + VALUES + ('شروع رشد', 'start-growth', 3, 1990000, 500), + ('رشد پایدار', 'stable-growth', 6, 3790000, 1200), + ('حرفه‌ای', 'professional', 12, 6990000, 3000) + `); + + await queryRunner.query(` + INSERT INTO sms_packages (name, code, "smsCount", price) + VALUES + ('بسته پایه', 'basic', 1000, 390000), + ('بسته حرفه‌ای', 'professional', 5000, 1850000), + ('بسته ویژه', 'special', 10000, 3400000) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('salon_sms_purchases', true); + await queryRunner.dropTable('salon_subscriptions', true); + await queryRunner.dropTable('sms_packages', true); + await queryRunner.dropTable('subscription_plans', true); + await queryRunner.query( + `DROP TYPE "public"."salon_subscriptions_status_enum"`, + ); + } +} diff --git a/src/salons/salons.controller.ts b/src/salons/salons.controller.ts index d559eff..e79e489 100644 --- a/src/salons/salons.controller.ts +++ b/src/salons/salons.controller.ts @@ -18,6 +18,7 @@ import { PermissionsGuard } from '../auth/guards/permissions.guard'; import { RolesGuard } from '../auth/guards/roles.guard'; import { AuthUser } from '../auth/interfaces/auth-user.interface'; import { PermissionCode } from '../authorization/enums/permission-code.enum'; +import { AllowWithoutSubscription } from '../auth/decorators/allow-without-subscription.decorator'; import { UserRole } from '../users/enums/user-role.enum'; import { CreateSalonDto } from './dto/create-salon.dto'; import { UpdateSalonDto } from './dto/update-salon.dto'; @@ -30,6 +31,7 @@ export class SalonsController { @Roles(UserRole.ADMIN, UserRole.SALON) @Permissions(PermissionCode.SALONS_WRITE) + @AllowWithoutSubscription() @Post() create( @Body() createSalonDto: CreateSalonDto, diff --git a/src/subscriptions/constants/subscription.constants.ts b/src/subscriptions/constants/subscription.constants.ts new file mode 100644 index 0000000..105be9f --- /dev/null +++ b/src/subscriptions/constants/subscription.constants.ts @@ -0,0 +1 @@ +export const SMS_ROLLOVER_GRACE_DAYS = 30; diff --git a/src/subscriptions/dto/create-sms-package.dto.ts b/src/subscriptions/dto/create-sms-package.dto.ts new file mode 100644 index 0000000..4b758ac --- /dev/null +++ b/src/subscriptions/dto/create-sms-package.dto.ts @@ -0,0 +1,33 @@ +import { + IsBoolean, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Length, + Min, +} from 'class-validator'; + +export class CreateSmsPackageDto { + @IsString() + @Length(1, 100) + @IsNotEmpty() + name: string; + + @IsString() + @Length(1, 50) + @IsNotEmpty() + code: string; + + @IsInt() + @Min(1) + smsCount: number; + + @IsInt() + @Min(0) + price: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/subscriptions/dto/create-subscription-plan.dto.ts b/src/subscriptions/dto/create-subscription-plan.dto.ts new file mode 100644 index 0000000..99442de --- /dev/null +++ b/src/subscriptions/dto/create-subscription-plan.dto.ts @@ -0,0 +1,37 @@ +import { + IsBoolean, + IsInt, + IsNotEmpty, + IsOptional, + IsString, + Length, + Min, +} from 'class-validator'; + +export class CreateSubscriptionPlanDto { + @IsString() + @Length(1, 100) + @IsNotEmpty() + name: string; + + @IsString() + @Length(1, 50) + @IsNotEmpty() + code: string; + + @IsInt() + @Min(1) + durationMonths: number; + + @IsInt() + @Min(0) + price: number; + + @IsInt() + @Min(0) + freeSmsCount: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/subscriptions/dto/purchase-sms-package.dto.ts b/src/subscriptions/dto/purchase-sms-package.dto.ts new file mode 100644 index 0000000..295737d --- /dev/null +++ b/src/subscriptions/dto/purchase-sms-package.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsUUID } from 'class-validator'; + +export class PurchaseSmsPackageDto { + @IsUUID() + @IsNotEmpty() + salonId: string; + + @IsUUID() + @IsNotEmpty() + packageId: string; +} diff --git a/src/subscriptions/dto/purchase-subscription.dto.ts b/src/subscriptions/dto/purchase-subscription.dto.ts new file mode 100644 index 0000000..d22d841 --- /dev/null +++ b/src/subscriptions/dto/purchase-subscription.dto.ts @@ -0,0 +1,11 @@ +import { IsNotEmpty, IsUUID } from 'class-validator'; + +export class PurchaseSubscriptionDto { + @IsUUID() + @IsNotEmpty() + salonId: string; + + @IsUUID() + @IsNotEmpty() + planId: string; +} diff --git a/src/subscriptions/dto/update-sms-package.dto.ts b/src/subscriptions/dto/update-sms-package.dto.ts new file mode 100644 index 0000000..6c90546 --- /dev/null +++ b/src/subscriptions/dto/update-sms-package.dto.ts @@ -0,0 +1,34 @@ +import { + IsBoolean, + IsInt, + IsOptional, + IsString, + Length, + Min, +} from 'class-validator'; + +export class UpdateSmsPackageDto { + @IsOptional() + @IsString() + @Length(1, 100) + name?: string; + + @IsOptional() + @IsString() + @Length(1, 50) + code?: string; + + @IsOptional() + @IsInt() + @Min(1) + smsCount?: number; + + @IsOptional() + @IsInt() + @Min(0) + price?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/subscriptions/dto/update-subscription-plan.dto.ts b/src/subscriptions/dto/update-subscription-plan.dto.ts new file mode 100644 index 0000000..b7d5bf3 --- /dev/null +++ b/src/subscriptions/dto/update-subscription-plan.dto.ts @@ -0,0 +1,39 @@ +import { + IsBoolean, + IsInt, + IsOptional, + IsString, + Length, + Min, +} from 'class-validator'; + +export class UpdateSubscriptionPlanDto { + @IsOptional() + @IsString() + @Length(1, 100) + name?: string; + + @IsOptional() + @IsString() + @Length(1, 50) + code?: string; + + @IsOptional() + @IsInt() + @Min(1) + durationMonths?: number; + + @IsOptional() + @IsInt() + @Min(0) + price?: number; + + @IsOptional() + @IsInt() + @Min(0) + freeSmsCount?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} diff --git a/src/subscriptions/entities/salon-sms-purchase.entity.ts b/src/subscriptions/entities/salon-sms-purchase.entity.ts new file mode 100644 index 0000000..8dce572 --- /dev/null +++ b/src/subscriptions/entities/salon-sms-purchase.entity.ts @@ -0,0 +1,46 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Salon } from '../../salons/entities/salon.entity'; +import { SmsPackage } from './sms-package.entity'; + +@Entity('salon_sms_purchases') +export class SalonSmsPurchase { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + salonId: string; + + @ManyToOne(() => Salon, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'salonId' }) + salon: Salon; + + @Column() + packageId: string; + + @ManyToOne(() => SmsPackage) + @JoinColumn({ name: 'packageId' }) + smsPackage: SmsPackage; + + @Column({ type: 'int' }) + smsCount: number; + + @Column({ type: 'int' }) + smsRemaining: number; + + @Column({ type: 'bigint' }) + pricePaid: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/subscriptions/entities/salon-subscription.entity.ts b/src/subscriptions/entities/salon-subscription.entity.ts new file mode 100644 index 0000000..edc3c94 --- /dev/null +++ b/src/subscriptions/entities/salon-subscription.entity.ts @@ -0,0 +1,60 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Salon } from '../../salons/entities/salon.entity'; +import { SalonSubscriptionStatus } from '../enums/salon-subscription-status.enum'; +import { SubscriptionPlan } from './subscription-plan.entity'; + +@Entity('salon_subscriptions') +export class SalonSubscription { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column() + salonId: string; + + @ManyToOne(() => Salon, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'salonId' }) + salon: Salon; + + @Column() + planId: string; + + @ManyToOne(() => SubscriptionPlan) + @JoinColumn({ name: 'planId' }) + plan: SubscriptionPlan; + + @Column({ + type: 'enum', + enum: SalonSubscriptionStatus, + default: SalonSubscriptionStatus.ACTIVE, + }) + status: SalonSubscriptionStatus; + + @Column({ type: 'timestamp' }) + startDate: Date; + + @Column({ type: 'timestamp' }) + endDate: Date; + + @Column({ type: 'bigint' }) + pricePaid: string; + + @Column({ type: 'int' }) + freeSmsGranted: number; + + @Column({ type: 'int' }) + freeSmsRemaining: number; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/subscriptions/entities/sms-package.entity.ts b/src/subscriptions/entities/sms-package.entity.ts new file mode 100644 index 0000000..84cda17 --- /dev/null +++ b/src/subscriptions/entities/sms-package.entity.ts @@ -0,0 +1,34 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('sms_packages') +export class SmsPackage { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 100 }) + name: string; + + @Column({ length: 50, unique: true }) + code: string; + + @Column({ type: 'int' }) + smsCount: number; + + @Column({ type: 'bigint' }) + price: string; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/subscriptions/entities/subscription-plan.entity.ts b/src/subscriptions/entities/subscription-plan.entity.ts new file mode 100644 index 0000000..da13a1c --- /dev/null +++ b/src/subscriptions/entities/subscription-plan.entity.ts @@ -0,0 +1,37 @@ +import { + Column, + CreateDateColumn, + Entity, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; + +@Entity('subscription_plans') +export class SubscriptionPlan { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 100 }) + name: string; + + @Column({ length: 50, unique: true }) + code: string; + + @Column({ type: 'int' }) + durationMonths: number; + + @Column({ type: 'bigint' }) + price: string; + + @Column({ type: 'int' }) + freeSmsCount: number; + + @Column({ default: true }) + isActive: boolean; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/subscriptions/enums/salon-subscription-status.enum.ts b/src/subscriptions/enums/salon-subscription-status.enum.ts new file mode 100644 index 0000000..f82bd7d --- /dev/null +++ b/src/subscriptions/enums/salon-subscription-status.enum.ts @@ -0,0 +1,5 @@ +export enum SalonSubscriptionStatus { + ACTIVE = 'active', + EXPIRED = 'expired', + CANCELLED = 'cancelled', +} diff --git a/src/subscriptions/guards/active-subscription.guard.ts b/src/subscriptions/guards/active-subscription.guard.ts new file mode 100644 index 0000000..c1583c3 --- /dev/null +++ b/src/subscriptions/guards/active-subscription.guard.ts @@ -0,0 +1,60 @@ +import { + CanActivate, + ExecutionContext, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { Reflector } from '@nestjs/core'; +import { IS_PUBLIC_KEY } from '../../auth/decorators/public.decorator'; +import { AuthUser } from '../../auth/interfaces/auth-user.interface'; +import { UserRole } from '../../users/enums/user-role.enum'; +import { ALLOW_WITHOUT_SUBSCRIPTION_KEY } from '../../auth/decorators/allow-without-subscription.decorator'; +import { SalonSubscriptionsService } from '../salon-subscriptions.service'; + +@Injectable() +export class ActiveSubscriptionGuard implements CanActivate { + constructor( + private readonly reflector: Reflector, + private readonly salonSubscriptionsService: SalonSubscriptionsService, + ) {} + + async canActivate(context: ExecutionContext): Promise { + const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ + context.getHandler(), + context.getClass(), + ]); + if (isPublic) { + return true; + } + + const allowWithoutSubscription = this.reflector.getAllAndOverride( + ALLOW_WITHOUT_SUBSCRIPTION_KEY, + [context.getHandler(), context.getClass()], + ); + if (allowWithoutSubscription) { + return true; + } + + const request = context.switchToHttp().getRequest<{ user?: AuthUser }>(); + const user = request.user; + if (!user) { + return true; + } + + if (user.role !== UserRole.SALON) { + return true; + } + + const hasActive = + await this.salonSubscriptionsService.hasActiveSubscriptionForOwner( + user.id, + ); + if (!hasActive) { + throw new ForbiddenException( + 'Active subscription is required to access this resource', + ); + } + + return true; + } +} diff --git a/src/subscriptions/interfaces/salon-sms-balance.interface.ts b/src/subscriptions/interfaces/salon-sms-balance.interface.ts new file mode 100644 index 0000000..de45a63 --- /dev/null +++ b/src/subscriptions/interfaces/salon-sms-balance.interface.ts @@ -0,0 +1,5 @@ +export interface SalonSmsBalance { + freeSmsRemaining: number; + purchasedSmsRemaining: number; + totalRemaining: number; +} diff --git a/src/subscriptions/salon-subscriptions.controller.ts b/src/subscriptions/salon-subscriptions.controller.ts new file mode 100644 index 0000000..f7edceb --- /dev/null +++ b/src/subscriptions/salon-subscriptions.controller.ts @@ -0,0 +1,107 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + UseGuards, +} from '@nestjs/common'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Permissions } from '../auth/decorators/permissions.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { PermissionCode } from '../authorization/enums/permission-code.enum'; +import { UserRole } from '../users/enums/user-role.enum'; +import { AllowWithoutSubscription } from '../auth/decorators/allow-without-subscription.decorator'; +import { PurchaseSmsPackageDto } from './dto/purchase-sms-package.dto'; +import { PurchaseSubscriptionDto } from './dto/purchase-subscription.dto'; +import { SalonSubscriptionsService } from './salon-subscriptions.service'; + +@Controller('salon-subscriptions') +@UseGuards(RolesGuard, PermissionsGuard) +export class SalonSubscriptionsController { + constructor( + private readonly salonSubscriptionsService: SalonSubscriptionsService, + ) {} + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Permissions(PermissionCode.SUBSCRIPTIONS_WRITE) + @AllowWithoutSubscription() + @Post('purchase') + purchaseSubscription( + @Body() dto: PurchaseSubscriptionDto, + @CurrentUser() user: AuthUser, + ) { + return this.salonSubscriptionsService.purchaseSubscription(dto, user); + } + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Permissions(PermissionCode.SUBSCRIPTIONS_WRITE) + @Post('sms/purchase') + purchaseSmsPackage( + @Body() dto: PurchaseSmsPackageDto, + @CurrentUser() user: AuthUser, + ) { + return this.salonSubscriptionsService.purchaseSmsPackage(dto, user); + } + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Permissions(PermissionCode.SUBSCRIPTIONS_READ) + @AllowWithoutSubscription() + @Get('salon/:salonId') + findAllForSalon( + @Param('salonId', ParseUUIDPipe) salonId: string, + @CurrentUser() user: AuthUser, + ) { + return this.salonSubscriptionsService.findAllForSalon(salonId, user); + } + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Permissions(PermissionCode.SUBSCRIPTIONS_READ) + @AllowWithoutSubscription() + @Get('salon/:salonId/active') + findActiveForSalon( + @Param('salonId', ParseUUIDPipe) salonId: string, + @CurrentUser() user: AuthUser, + ) { + return this.salonSubscriptionsService.findActiveForSalon(salonId, user); + } + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Permissions(PermissionCode.SUBSCRIPTIONS_READ) + @AllowWithoutSubscription() + @Get('salon/:salonId/sms-balance') + getSmsBalance( + @Param('salonId', ParseUUIDPipe) salonId: string, + @CurrentUser() user: AuthUser, + ) { + return this.salonSubscriptionsService.getSmsBalance(salonId, user); + } + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Permissions(PermissionCode.SUBSCRIPTIONS_READ) + @Get('salon/:salonId/sms-purchases') + findSmsPurchasesForSalon( + @Param('salonId', ParseUUIDPipe) salonId: string, + @CurrentUser() user: AuthUser, + ) { + return this.salonSubscriptionsService.findSmsPurchasesForSalon( + salonId, + user, + ); + } + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Permissions(PermissionCode.SUBSCRIPTIONS_READ) + @AllowWithoutSubscription() + @Get(':id') + findOne( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + return this.salonSubscriptionsService.findOne(id, user); + } +} diff --git a/src/subscriptions/salon-subscriptions.service.ts b/src/subscriptions/salon-subscriptions.service.ts new file mode 100644 index 0000000..b4b0be0 --- /dev/null +++ b/src/subscriptions/salon-subscriptions.service.ts @@ -0,0 +1,327 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { SubscriptionPolicy } from '../authorization/policies/subscription.policy'; +import { SalonsService } from '../salons/salons.service'; +import { SMS_ROLLOVER_GRACE_DAYS } from './constants/subscription.constants'; +import { PurchaseSmsPackageDto } from './dto/purchase-sms-package.dto'; +import { PurchaseSubscriptionDto } from './dto/purchase-subscription.dto'; +import { SalonSmsPurchase } from './entities/salon-sms-purchase.entity'; +import { SalonSubscription } from './entities/salon-subscription.entity'; +import { SalonSubscriptionStatus } from './enums/salon-subscription-status.enum'; +import { SalonSmsBalance } from './interfaces/salon-sms-balance.interface'; +import { SmsPackagesService } from './sms-packages.service'; +import { SubscriptionPlansService } from './subscription-plans.service'; + +@Injectable() +export class SalonSubscriptionsService { + constructor( + @InjectRepository(SalonSubscription) + private readonly subscriptionsRepository: Repository, + @InjectRepository(SalonSmsPurchase) + private readonly smsPurchasesRepository: Repository, + private readonly subscriptionPlansService: SubscriptionPlansService, + private readonly smsPackagesService: SmsPackagesService, + private readonly salonsService: SalonsService, + private readonly subscriptionPolicy: SubscriptionPolicy, + ) {} + + async purchaseSubscription( + dto: PurchaseSubscriptionDto, + user: AuthUser, + ): Promise { + const salon = await this.salonsService.findOne(dto.salonId); + if (!this.subscriptionPolicy.canPurchase(user, salon)) { + throw new ForbiddenException(); + } + + const plan = await this.subscriptionPlansService.findActiveById(dto.planId); + const activeSubscription = await this.findActiveSubscription(dto.salonId); + if (activeSubscription) { + throw new BadRequestException('Salon already has an active subscription'); + } + + const now = new Date(); + const rolloverSms = await this.calculateSmsRollover(dto.salonId, now); + const freeSmsGranted = plan.freeSmsCount + rolloverSms; + + const subscription = this.subscriptionsRepository.create({ + salonId: dto.salonId, + planId: plan.id, + status: SalonSubscriptionStatus.ACTIVE, + startDate: now, + endDate: this.addMonths(now, plan.durationMonths), + pricePaid: plan.price, + freeSmsGranted, + freeSmsRemaining: freeSmsGranted, + }); + + const saved = await this.subscriptionsRepository.save(subscription); + return this.findOne(saved.id, user); + } + + async purchaseSmsPackage( + dto: PurchaseSmsPackageDto, + user: AuthUser, + ): Promise { + const salon = await this.salonsService.findOne(dto.salonId); + if (!this.subscriptionPolicy.canPurchase(user, salon)) { + throw new ForbiddenException(); + } + + const hasActive = await this.hasActiveSubscription(dto.salonId); + if (!hasActive) { + throw new BadRequestException( + 'Salon must have an active subscription to purchase SMS packages', + ); + } + + const pkg = await this.smsPackagesService.findActiveById(dto.packageId); + const purchase = this.smsPurchasesRepository.create({ + salonId: dto.salonId, + packageId: pkg.id, + smsCount: pkg.smsCount, + smsRemaining: pkg.smsCount, + pricePaid: pkg.price, + }); + + const saved = await this.smsPurchasesRepository.save(purchase); + return this.findSmsPurchase(saved.id, user); + } + + async findAllForSalon( + salonId: string, + user: AuthUser, + ): Promise { + const salon = await this.salonsService.findOne(salonId); + if (!this.subscriptionPolicy.canRead(user, salon)) { + throw new ForbiddenException(); + } + + return this.subscriptionsRepository.find({ + where: { salonId }, + relations: { plan: true }, + order: { createdAt: 'DESC' }, + }); + } + + async findActiveForSalon( + salonId: string, + user: AuthUser, + ): Promise { + const salon = await this.salonsService.findOne(salonId); + if (!this.subscriptionPolicy.canRead(user, salon)) { + throw new ForbiddenException(); + } + + return this.findActiveSubscription(salonId); + } + + async getSmsBalance(salonId: string, user: AuthUser): Promise { + const salon = await this.salonsService.findOne(salonId); + if (!this.subscriptionPolicy.canRead(user, salon)) { + throw new ForbiddenException(); + } + + return this.computeSmsBalance(salonId); + } + + async findSmsPurchasesForSalon( + salonId: string, + user: AuthUser, + ): Promise { + const salon = await this.salonsService.findOne(salonId); + if (!this.subscriptionPolicy.canRead(user, salon)) { + throw new ForbiddenException(); + } + + return this.smsPurchasesRepository.find({ + where: { salonId }, + relations: { smsPackage: true }, + order: { createdAt: 'DESC' }, + }); + } + + async hasActiveSubscription(salonId: string): Promise { + const subscription = await this.findActiveSubscription(salonId); + return subscription !== null; + } + + async hasActiveSubscriptionForOwner(ownerId: string): Promise { + const subscriptions = await this.subscriptionsRepository + .createQueryBuilder('subscription') + .innerJoin('subscription.salon', 'salon') + .where('salon.ownerId = :ownerId', { ownerId }) + .andWhere('subscription.status = :status', { + status: SalonSubscriptionStatus.ACTIVE, + }) + .getMany(); + + const now = new Date(); + for (const subscription of subscriptions) { + if (subscription.endDate > now) { + return true; + } + subscription.status = SalonSubscriptionStatus.EXPIRED; + await this.subscriptionsRepository.save(subscription); + } + + return false; + } + + async consumeSms(salonId: string, count = 1): Promise { + if (count <= 0) { + throw new BadRequestException('SMS count must be positive'); + } + + const hasActive = await this.hasActiveSubscription(salonId); + if (!hasActive) { + throw new BadRequestException('Salon does not have an active subscription'); + } + + let remaining = count; + const activeSubscription = await this.findActiveSubscription(salonId); + + if (activeSubscription && activeSubscription.freeSmsRemaining > 0) { + const fromFree = Math.min(remaining, activeSubscription.freeSmsRemaining); + activeSubscription.freeSmsRemaining -= fromFree; + remaining -= fromFree; + await this.subscriptionsRepository.save(activeSubscription); + } + + if (remaining > 0) { + const purchases = await this.smsPurchasesRepository.find({ + where: { salonId }, + order: { createdAt: 'ASC' }, + }); + + for (const purchase of purchases) { + if (remaining <= 0) break; + if (purchase.smsRemaining <= 0) continue; + + const fromPurchase = Math.min(remaining, purchase.smsRemaining); + purchase.smsRemaining -= fromPurchase; + remaining -= fromPurchase; + await this.smsPurchasesRepository.save(purchase); + } + } + + if (remaining > 0) { + throw new BadRequestException('Insufficient SMS balance'); + } + } + + async findOne(id: string, user: AuthUser): Promise { + const subscription = await this.subscriptionsRepository.findOne({ + where: { id }, + relations: { plan: true, salon: true }, + }); + if (!subscription) { + throw new NotFoundException(`Salon subscription #${id} not found`); + } + if (!this.subscriptionPolicy.canRead(user, subscription.salon)) { + throw new ForbiddenException(); + } + return subscription; + } + + private async findSmsPurchase( + id: string, + user: AuthUser, + ): Promise { + const purchase = await this.smsPurchasesRepository.findOne({ + where: { id }, + relations: { smsPackage: true, salon: true }, + }); + if (!purchase) { + throw new NotFoundException(`SMS purchase #${id} not found`); + } + if (!this.subscriptionPolicy.canRead(user, purchase.salon)) { + throw new ForbiddenException(); + } + return purchase; + } + + private async findActiveSubscription( + salonId: string, + ): Promise { + const subscription = await this.subscriptionsRepository.findOne({ + where: { salonId, status: SalonSubscriptionStatus.ACTIVE }, + relations: { plan: true }, + order: { createdAt: 'DESC' }, + }); + + if (!subscription) { + return null; + } + + if (subscription.endDate <= new Date()) { + subscription.status = SalonSubscriptionStatus.EXPIRED; + await this.subscriptionsRepository.save(subscription); + return null; + } + + return subscription; + } + + private async calculateSmsRollover( + salonId: string, + now: Date, + ): Promise { + const lastSubscription = await this.subscriptionsRepository.findOne({ + where: { salonId }, + order: { endDate: 'DESC' }, + }); + + if (!lastSubscription || lastSubscription.status === SalonSubscriptionStatus.ACTIVE) { + return 0; + } + + const graceDeadline = this.addDays( + lastSubscription.endDate, + SMS_ROLLOVER_GRACE_DAYS, + ); + if (now > graceDeadline || lastSubscription.freeSmsRemaining <= 0) { + return 0; + } + + return lastSubscription.freeSmsRemaining; + } + + private async computeSmsBalance(salonId: string): Promise { + const activeSubscription = await this.findActiveSubscription(salonId); + const freeSmsRemaining = activeSubscription?.freeSmsRemaining ?? 0; + + const purchases = await this.smsPurchasesRepository.find({ + where: { salonId }, + }); + const purchasedSmsRemaining = purchases.reduce( + (sum, purchase) => sum + purchase.smsRemaining, + 0, + ); + + return { + freeSmsRemaining, + purchasedSmsRemaining, + totalRemaining: freeSmsRemaining + purchasedSmsRemaining, + }; + } + + private addMonths(date: Date, months: number): Date { + const result = new Date(date); + result.setMonth(result.getMonth() + months); + return result; + } + + private addDays(date: Date, days: number): Date { + const result = new Date(date); + result.setDate(result.getDate() + days); + return result; + } +} diff --git a/src/subscriptions/sms-packages.controller.ts b/src/subscriptions/sms-packages.controller.ts new file mode 100644 index 0000000..9787700 --- /dev/null +++ b/src/subscriptions/sms-packages.controller.ts @@ -0,0 +1,66 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { Public } from '../auth/decorators/public.decorator'; +import { Permissions } from '../auth/decorators/permissions.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { PermissionCode } from '../authorization/enums/permission-code.enum'; +import { UserRole } from '../users/enums/user-role.enum'; +import { AllowWithoutSubscription } from '../auth/decorators/allow-without-subscription.decorator'; +import { CreateSmsPackageDto } from './dto/create-sms-package.dto'; +import { UpdateSmsPackageDto } from './dto/update-sms-package.dto'; +import { SmsPackagesService } from './sms-packages.service'; + +@Controller('sms-packages') +@UseGuards(RolesGuard, PermissionsGuard) +export class SmsPackagesController { + constructor(private readonly smsPackagesService: SmsPackagesService) {} + + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.SUBSCRIPTIONS_WRITE) + @Post() + create(@Body() dto: CreateSmsPackageDto) { + return this.smsPackagesService.create(dto); + } + + @Public() + @AllowWithoutSubscription() + @Get() + findAll() { + return this.smsPackagesService.findAll(true); + } + + @Public() + @AllowWithoutSubscription() + @Get(':id') + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.smsPackagesService.findOne(id); + } + + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.SUBSCRIPTIONS_WRITE) + @Patch(':id') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateSmsPackageDto, + ) { + return this.smsPackagesService.update(id, dto); + } + + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.SUBSCRIPTIONS_WRITE) + @Delete(':id') + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.smsPackagesService.remove(id); + } +} diff --git a/src/subscriptions/sms-packages.service.ts b/src/subscriptions/sms-packages.service.ts new file mode 100644 index 0000000..e722ced --- /dev/null +++ b/src/subscriptions/sms-packages.service.ts @@ -0,0 +1,65 @@ +import { + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateSmsPackageDto } from './dto/create-sms-package.dto'; +import { UpdateSmsPackageDto } from './dto/update-sms-package.dto'; +import { SmsPackage } from './entities/sms-package.entity'; + +@Injectable() +export class SmsPackagesService { + constructor( + @InjectRepository(SmsPackage) + private readonly packagesRepository: Repository, + ) {} + + create(dto: CreateSmsPackageDto): Promise { + const pkg = this.packagesRepository.create({ + ...dto, + price: String(dto.price), + }); + return this.packagesRepository.save(pkg); + } + + findAll(activeOnly = false): Promise { + return this.packagesRepository.find({ + where: activeOnly ? { isActive: true } : undefined, + order: { smsCount: 'ASC' }, + }); + } + + async findOne(id: string): Promise { + const pkg = await this.packagesRepository.findOne({ where: { id } }); + if (!pkg) { + throw new NotFoundException(`SMS package #${id} not found`); + } + return pkg; + } + + async findActiveById(id: string): Promise { + const pkg = await this.findOne(id); + if (!pkg.isActive) { + throw new NotFoundException(`SMS package #${id} is not available`); + } + return pkg; + } + + async update(id: string, dto: UpdateSmsPackageDto): Promise { + const pkg = await this.findOne(id); + if (dto.price !== undefined) { + pkg.price = String(dto.price); + } + if (dto.name !== undefined) pkg.name = dto.name; + if (dto.code !== undefined) pkg.code = dto.code; + if (dto.smsCount !== undefined) pkg.smsCount = dto.smsCount; + if (dto.isActive !== undefined) pkg.isActive = dto.isActive; + return this.packagesRepository.save(pkg); + } + + async remove(id: string): Promise { + const pkg = await this.findOne(id); + await this.packagesRepository.remove(pkg); + } +} diff --git a/src/subscriptions/subscription-plans.controller.ts b/src/subscriptions/subscription-plans.controller.ts new file mode 100644 index 0000000..dcb1907 --- /dev/null +++ b/src/subscriptions/subscription-plans.controller.ts @@ -0,0 +1,70 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + ParseUUIDPipe, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Public } from '../auth/decorators/public.decorator'; +import { Permissions } from '../auth/decorators/permissions.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { PermissionCode } from '../authorization/enums/permission-code.enum'; +import { UserRole } from '../users/enums/user-role.enum'; +import { AllowWithoutSubscription } from '../auth/decorators/allow-without-subscription.decorator'; +import { CreateSubscriptionPlanDto } from './dto/create-subscription-plan.dto'; +import { UpdateSubscriptionPlanDto } from './dto/update-subscription-plan.dto'; +import { SubscriptionPlansService } from './subscription-plans.service'; + +@Controller('subscription-plans') +@UseGuards(RolesGuard, PermissionsGuard) +export class SubscriptionPlansController { + constructor( + private readonly subscriptionPlansService: SubscriptionPlansService, + ) {} + + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.SUBSCRIPTIONS_WRITE) + @Post() + create(@Body() dto: CreateSubscriptionPlanDto) { + return this.subscriptionPlansService.create(dto); + } + + @Public() + @AllowWithoutSubscription() + @Get() + findAll() { + return this.subscriptionPlansService.findAll(true); + } + + @Public() + @AllowWithoutSubscription() + @Get(':id') + findOne(@Param('id', ParseUUIDPipe) id: string) { + return this.subscriptionPlansService.findOne(id); + } + + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.SUBSCRIPTIONS_WRITE) + @Patch(':id') + update( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: UpdateSubscriptionPlanDto, + ) { + return this.subscriptionPlansService.update(id, dto); + } + + @Roles(UserRole.ADMIN) + @Permissions(PermissionCode.SUBSCRIPTIONS_WRITE) + @Delete(':id') + remove(@Param('id', ParseUUIDPipe) id: string) { + return this.subscriptionPlansService.remove(id); + } +} diff --git a/src/subscriptions/subscription-plans.service.ts b/src/subscriptions/subscription-plans.service.ts new file mode 100644 index 0000000..d193067 --- /dev/null +++ b/src/subscriptions/subscription-plans.service.ts @@ -0,0 +1,69 @@ +import { + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { CreateSubscriptionPlanDto } from './dto/create-subscription-plan.dto'; +import { UpdateSubscriptionPlanDto } from './dto/update-subscription-plan.dto'; +import { SubscriptionPlan } from './entities/subscription-plan.entity'; + +@Injectable() +export class SubscriptionPlansService { + constructor( + @InjectRepository(SubscriptionPlan) + private readonly plansRepository: Repository, + ) {} + + create(dto: CreateSubscriptionPlanDto): Promise { + const plan = this.plansRepository.create({ + ...dto, + price: String(dto.price), + }); + return this.plansRepository.save(plan); + } + + findAll(activeOnly = false): Promise { + return this.plansRepository.find({ + where: activeOnly ? { isActive: true } : undefined, + order: { durationMonths: 'ASC' }, + }); + } + + async findOne(id: string): Promise { + const plan = await this.plansRepository.findOne({ where: { id } }); + if (!plan) { + throw new NotFoundException(`Subscription plan #${id} not found`); + } + return plan; + } + + async findActiveById(id: string): Promise { + const plan = await this.findOne(id); + if (!plan.isActive) { + throw new NotFoundException(`Subscription plan #${id} is not available`); + } + return plan; + } + + async update( + id: string, + dto: UpdateSubscriptionPlanDto, + ): Promise { + const plan = await this.findOne(id); + if (dto.price !== undefined) { + plan.price = String(dto.price); + } + if (dto.name !== undefined) plan.name = dto.name; + if (dto.code !== undefined) plan.code = dto.code; + if (dto.durationMonths !== undefined) plan.durationMonths = dto.durationMonths; + if (dto.freeSmsCount !== undefined) plan.freeSmsCount = dto.freeSmsCount; + if (dto.isActive !== undefined) plan.isActive = dto.isActive; + return this.plansRepository.save(plan); + } + + async remove(id: string): Promise { + const plan = await this.findOne(id); + await this.plansRepository.remove(plan); + } +} diff --git a/src/subscriptions/subscriptions.module.ts b/src/subscriptions/subscriptions.module.ts new file mode 100644 index 0000000..0cb3fee --- /dev/null +++ b/src/subscriptions/subscriptions.module.ts @@ -0,0 +1,47 @@ +import { Module } from '@nestjs/common'; +import { APP_GUARD } from '@nestjs/core'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { AuthGuardsModule } from '../auth/auth-guards.module'; +import { AuthorizationModule } from '../authorization/authorization.module'; +import { SalonsModule } from '../salons/salons.module'; +import { SalonSmsPurchase } from './entities/salon-sms-purchase.entity'; +import { SalonSubscription } from './entities/salon-subscription.entity'; +import { SmsPackage } from './entities/sms-package.entity'; +import { SubscriptionPlan } from './entities/subscription-plan.entity'; +import { ActiveSubscriptionGuard } from './guards/active-subscription.guard'; +import { SalonSubscriptionsController } from './salon-subscriptions.controller'; +import { SalonSubscriptionsService } from './salon-subscriptions.service'; +import { SmsPackagesController } from './sms-packages.controller'; +import { SmsPackagesService } from './sms-packages.service'; +import { SubscriptionPlansController } from './subscription-plans.controller'; +import { SubscriptionPlansService } from './subscription-plans.service'; + +@Module({ + imports: [ + TypeOrmModule.forFeature([ + SubscriptionPlan, + SmsPackage, + SalonSubscription, + SalonSmsPurchase, + ]), + SalonsModule, + AuthorizationModule, + AuthGuardsModule, + ], + controllers: [ + SubscriptionPlansController, + SmsPackagesController, + SalonSubscriptionsController, + ], + providers: [ + SubscriptionPlansService, + SmsPackagesService, + SalonSubscriptionsService, + { + provide: APP_GUARD, + useClass: ActiveSubscriptionGuard, + }, + ], + exports: [SalonSubscriptionsService], +}) +export class SubscriptionsModule {}