subscription
This commit is contained in:
@@ -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