subscription

This commit is contained in:
2026-07-02 19:54:55 +03:30
parent d79fa12d65
commit d20e5be9c8
29 changed files with 1402 additions and 0 deletions
+2
View File
@@ -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: [],
+2
View File
@@ -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);
@@ -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 { 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 {}
@@ -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',
}
@@ -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"`,
);
}
}
+2
View File
@@ -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,
@@ -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);
}
}
+65
View File
@@ -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);
}
}
+47
View File
@@ -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 {}