subscription
This commit is contained in:
@@ -12,6 +12,7 @@ import { SentMessagesModule } from './sent-messages/sent-messages.module';
|
|||||||
import { SmsTemplatesModule } from './sms-templates/sms-templates.module';
|
import { SmsTemplatesModule } from './sms-templates/sms-templates.module';
|
||||||
import { SuggestionsModule } from './suggestions/suggestions.module';
|
import { SuggestionsModule } from './suggestions/suggestions.module';
|
||||||
import { StylistsModule } from './stylists/stylists.module';
|
import { StylistsModule } from './stylists/stylists.module';
|
||||||
|
import { SubscriptionsModule } from './subscriptions/subscriptions.module';
|
||||||
import { UsersModule } from './users/users.module';
|
import { UsersModule } from './users/users.module';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
@@ -31,6 +32,7 @@ import { UsersModule } from './users/users.module';
|
|||||||
SuggestionsModule,
|
SuggestionsModule,
|
||||||
SmsTemplatesModule,
|
SmsTemplatesModule,
|
||||||
SentMessagesModule,
|
SentMessagesModule,
|
||||||
|
SubscriptionsModule,
|
||||||
],
|
],
|
||||||
controllers: [],
|
controllers: [],
|
||||||
providers: [],
|
providers: [],
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { Body, Controller, Post } from '@nestjs/common';
|
import { Body, Controller, Post } from '@nestjs/common';
|
||||||
|
import { AllowWithoutSubscription } from '../auth/decorators/allow-without-subscription.decorator';
|
||||||
import { AuthService } from './auth.service';
|
import { AuthService } from './auth.service';
|
||||||
import { CurrentUser } from './decorators/current-user.decorator';
|
import { CurrentUser } from './decorators/current-user.decorator';
|
||||||
import { Public } from './decorators/public.decorator';
|
import { Public } from './decorators/public.decorator';
|
||||||
@@ -29,6 +30,7 @@ export class AuthController {
|
|||||||
return this.authService.refresh(dto.refreshToken);
|
return this.authService.refresh(dto.refreshToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@AllowWithoutSubscription()
|
||||||
@Post('logout')
|
@Post('logout')
|
||||||
logout(@CurrentUser() user: AuthUser, @Body() dto: RefreshTokenDto) {
|
logout(@CurrentUser() user: AuthUser, @Body() dto: RefreshTokenDto) {
|
||||||
return this.authService.logout(user.id, dto.refreshToken);
|
return this.authService.logout(user.id, dto.refreshToken);
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -9,6 +9,7 @@ import { ReservePolicy } from './policies/reserve.policy';
|
|||||||
import { SalonPolicy } from './policies/salon.policy';
|
import { SalonPolicy } from './policies/salon.policy';
|
||||||
import { ServicePolicy } from './policies/service.policy';
|
import { ServicePolicy } from './policies/service.policy';
|
||||||
import { StylistPolicy } from './policies/stylist.policy';
|
import { StylistPolicy } from './policies/stylist.policy';
|
||||||
|
import { SubscriptionPolicy } from './policies/subscription.policy';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [TypeOrmModule.forFeature([Permission, UserPermission, Reserve])],
|
imports: [TypeOrmModule.forFeature([Permission, UserPermission, Reserve])],
|
||||||
@@ -19,6 +20,7 @@ import { StylistPolicy } from './policies/stylist.policy';
|
|||||||
CustomerPolicy,
|
CustomerPolicy,
|
||||||
StylistPolicy,
|
StylistPolicy,
|
||||||
ReservePolicy,
|
ReservePolicy,
|
||||||
|
SubscriptionPolicy,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
AuthorizationService,
|
AuthorizationService,
|
||||||
@@ -27,6 +29,7 @@ import { StylistPolicy } from './policies/stylist.policy';
|
|||||||
CustomerPolicy,
|
CustomerPolicy,
|
||||||
StylistPolicy,
|
StylistPolicy,
|
||||||
ReservePolicy,
|
ReservePolicy,
|
||||||
|
SubscriptionPolicy,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class AuthorizationModule {}
|
export class AuthorizationModule {}
|
||||||
|
|||||||
@@ -18,4 +18,6 @@ export enum PermissionCode {
|
|||||||
SERVICES_DELETE = 'services:delete',
|
SERVICES_DELETE = 'services:delete',
|
||||||
RESERVES_READ = 'reserves:read',
|
RESERVES_READ = 'reserves:read',
|
||||||
RESERVES_WRITE = 'reserves:write',
|
RESERVES_WRITE = 'reserves:write',
|
||||||
|
SUBSCRIPTIONS_READ = 'subscriptions:read',
|
||||||
|
SUBSCRIPTIONS_WRITE = 'subscriptions:write',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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"`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import { PermissionsGuard } from '../auth/guards/permissions.guard';
|
|||||||
import { RolesGuard } from '../auth/guards/roles.guard';
|
import { RolesGuard } from '../auth/guards/roles.guard';
|
||||||
import { AuthUser } from '../auth/interfaces/auth-user.interface';
|
import { AuthUser } from '../auth/interfaces/auth-user.interface';
|
||||||
import { PermissionCode } from '../authorization/enums/permission-code.enum';
|
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 { UserRole } from '../users/enums/user-role.enum';
|
||||||
import { CreateSalonDto } from './dto/create-salon.dto';
|
import { CreateSalonDto } from './dto/create-salon.dto';
|
||||||
import { UpdateSalonDto } from './dto/update-salon.dto';
|
import { UpdateSalonDto } from './dto/update-salon.dto';
|
||||||
@@ -30,6 +31,7 @@ export class SalonsController {
|
|||||||
|
|
||||||
@Roles(UserRole.ADMIN, UserRole.SALON)
|
@Roles(UserRole.ADMIN, UserRole.SALON)
|
||||||
@Permissions(PermissionCode.SALONS_WRITE)
|
@Permissions(PermissionCode.SALONS_WRITE)
|
||||||
|
@AllowWithoutSubscription()
|
||||||
@Post()
|
@Post()
|
||||||
create(
|
create(
|
||||||
@Body() createSalonDto: CreateSalonDto,
|
@Body() createSalonDto: CreateSalonDto,
|
||||||
|
|||||||
@@ -0,0 +1 @@
|
|||||||
|
export const SMS_ROLLOVER_GRACE_DAYS = 30;
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class PurchaseSmsPackageDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
salonId: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
packageId: string;
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { IsNotEmpty, IsUUID } from 'class-validator';
|
||||||
|
|
||||||
|
export class PurchaseSubscriptionDto {
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
salonId: string;
|
||||||
|
|
||||||
|
@IsUUID()
|
||||||
|
@IsNotEmpty()
|
||||||
|
planId: string;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export enum SalonSubscriptionStatus {
|
||||||
|
ACTIVE = 'active',
|
||||||
|
EXPIRED = 'expired',
|
||||||
|
CANCELLED = 'cancelled',
|
||||||
|
}
|
||||||
@@ -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<boolean> {
|
||||||
|
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
|
||||||
|
context.getHandler(),
|
||||||
|
context.getClass(),
|
||||||
|
]);
|
||||||
|
if (isPublic) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const allowWithoutSubscription = this.reflector.getAllAndOverride<boolean>(
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export interface SalonSmsBalance {
|
||||||
|
freeSmsRemaining: number;
|
||||||
|
purchasedSmsRemaining: number;
|
||||||
|
totalRemaining: number;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SalonSubscription>,
|
||||||
|
@InjectRepository(SalonSmsPurchase)
|
||||||
|
private readonly smsPurchasesRepository: Repository<SalonSmsPurchase>,
|
||||||
|
private readonly subscriptionPlansService: SubscriptionPlansService,
|
||||||
|
private readonly smsPackagesService: SmsPackagesService,
|
||||||
|
private readonly salonsService: SalonsService,
|
||||||
|
private readonly subscriptionPolicy: SubscriptionPolicy,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
async purchaseSubscription(
|
||||||
|
dto: PurchaseSubscriptionDto,
|
||||||
|
user: AuthUser,
|
||||||
|
): Promise<SalonSubscription> {
|
||||||
|
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<SalonSmsPurchase> {
|
||||||
|
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<SalonSubscription[]> {
|
||||||
|
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<SalonSubscription | null> {
|
||||||
|
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<SalonSmsBalance> {
|
||||||
|
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<SalonSmsPurchase[]> {
|
||||||
|
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<boolean> {
|
||||||
|
const subscription = await this.findActiveSubscription(salonId);
|
||||||
|
return subscription !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
async hasActiveSubscriptionForOwner(ownerId: string): Promise<boolean> {
|
||||||
|
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<void> {
|
||||||
|
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<SalonSubscription> {
|
||||||
|
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<SalonSmsPurchase> {
|
||||||
|
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<SalonSubscription | null> {
|
||||||
|
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<number> {
|
||||||
|
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<SalonSmsBalance> {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SmsPackage>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
create(dto: CreateSmsPackageDto): Promise<SmsPackage> {
|
||||||
|
const pkg = this.packagesRepository.create({
|
||||||
|
...dto,
|
||||||
|
price: String(dto.price),
|
||||||
|
});
|
||||||
|
return this.packagesRepository.save(pkg);
|
||||||
|
}
|
||||||
|
|
||||||
|
findAll(activeOnly = false): Promise<SmsPackage[]> {
|
||||||
|
return this.packagesRepository.find({
|
||||||
|
where: activeOnly ? { isActive: true } : undefined,
|
||||||
|
order: { smsCount: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string): Promise<SmsPackage> {
|
||||||
|
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<SmsPackage> {
|
||||||
|
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<SmsPackage> {
|
||||||
|
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<void> {
|
||||||
|
const pkg = await this.findOne(id);
|
||||||
|
await this.packagesRepository.remove(pkg);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<SubscriptionPlan>,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
create(dto: CreateSubscriptionPlanDto): Promise<SubscriptionPlan> {
|
||||||
|
const plan = this.plansRepository.create({
|
||||||
|
...dto,
|
||||||
|
price: String(dto.price),
|
||||||
|
});
|
||||||
|
return this.plansRepository.save(plan);
|
||||||
|
}
|
||||||
|
|
||||||
|
findAll(activeOnly = false): Promise<SubscriptionPlan[]> {
|
||||||
|
return this.plansRepository.find({
|
||||||
|
where: activeOnly ? { isActive: true } : undefined,
|
||||||
|
order: { durationMonths: 'ASC' },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async findOne(id: string): Promise<SubscriptionPlan> {
|
||||||
|
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<SubscriptionPlan> {
|
||||||
|
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<SubscriptionPlan> {
|
||||||
|
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<void> {
|
||||||
|
const plan = await this.findOne(id);
|
||||||
|
await this.plansRepository.remove(plan);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
Reference in New Issue
Block a user