From 816cb639e750b43055aa2fcd619657ad969f062d Mon Sep 17 00:00:00 2001 From: morteza-mortezai Date: Sat, 6 Dec 2025 12:50:51 +0330 Subject: [PATCH] coupon module --- src/app.module.ts | 2 + src/common/enums/message.enum.ts | 17 ++ .../coupons/controllers/coupon.controller.ts | 99 +++++++ src/modules/coupons/coupon.module.ts | 18 ++ src/modules/coupons/dto/create-coupon.dto.ts | 78 ++++++ src/modules/coupons/dto/find-coupons.dto.ts | 45 ++++ src/modules/coupons/dto/update-coupon.dto.ts | 5 + .../coupons/dto/validate-coupon.dto.ts | 22 ++ src/modules/coupons/entities/coupon.entity.ts | 53 ++++ .../coupons/providers/coupon.service.ts | 253 ++++++++++++++++++ .../coupons/repositories/coupon.repository.ts | 66 +++++ src/modules/orders/entities/order.entity.ts | 2 +- src/seeders/DatabaseSeeder.ts | 9 +- src/seeders/coupons.seeder.ts | 43 +++ src/seeders/data/coupons.data.ts | 95 +++++++ 15 files changed, 804 insertions(+), 3 deletions(-) create mode 100644 src/modules/coupons/controllers/coupon.controller.ts create mode 100644 src/modules/coupons/coupon.module.ts create mode 100644 src/modules/coupons/dto/create-coupon.dto.ts create mode 100644 src/modules/coupons/dto/find-coupons.dto.ts create mode 100644 src/modules/coupons/dto/update-coupon.dto.ts create mode 100644 src/modules/coupons/dto/validate-coupon.dto.ts create mode 100644 src/modules/coupons/entities/coupon.entity.ts create mode 100644 src/modules/coupons/providers/coupon.service.ts create mode 100644 src/modules/coupons/repositories/coupon.repository.ts create mode 100644 src/seeders/coupons.seeder.ts create mode 100644 src/seeders/data/coupons.data.ts diff --git a/src/app.module.ts b/src/app.module.ts index 93fe49a..25d2802 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -22,6 +22,7 @@ import { RolesModule } from './modules/roles/roles.module'; import { PaymentsModule } from './modules/payments/payments.module'; import { DeliveryModule } from './modules/delivery/delivery.module'; import { OrdersModule } from './modules/orders/orders.module'; +import { CouponModule } from './modules/coupons/coupon.module'; @Module({ imports: [ @@ -57,6 +58,7 @@ import { OrdersModule } from './modules/orders/orders.module'; PaymentsModule, DeliveryModule, OrdersModule, + CouponModule, ], controllers: [], providers: [], diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index e58c3fa..96c8bb2 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -620,3 +620,20 @@ export const enum TwoFactorMessage { TWO_FACTOR_TOKEN_REQUIRED = 'کد احراز هویت دو مرحله‌ای الزامی است', TWO_FACTOR_TOKEN_INVALID = 'کد احراز هویت دو مرحله‌ای نامعتبر است', } + +export const enum CouponMessage { + NOT_FOUND = 'کوپن یافت نشد', + CODE_ALREADY_EXISTS = 'کد کوپن قبلاً ثبت شده است', + END_DATE_MUST_BE_AFTER_START_DATE = 'تاریخ پایان باید بعد از تاریخ شروع باشد', + PERCENTAGE_MUST_BE_BETWEEN_0_AND_100 = 'درصد تخفیف باید بین ۰ تا ۱۰۰ باشد', + COUPON_INACTIVE = 'کوپن غیرفعال است', + COUPON_NOT_STARTED = 'کوپن هنوز شروع نشده است', + COUPON_EXPIRED = 'کوپن منقضی شده است', + COUPON_MAX_USES_REACHED = 'حداکثر استفاده از کوپن به پایان رسیده است', + MIN_ORDER_AMOUNT_NOT_MET = 'مبلغ سفارش کمتر از حداقل مجاز است', + COUPON_CREATED = 'کوپن با موفقیت ایجاد شد', + COUPON_UPDATED = 'کوپن با موفقیت به‌روزرسانی شد', + COUPON_DELETED = 'کوپن با موفقیت حذف شد', + COUPON_VALID = 'کوپن معتبر است', + COUPON_INVALID = 'کوپن نامعتبر است', +} diff --git a/src/modules/coupons/controllers/coupon.controller.ts b/src/modules/coupons/controllers/coupon.controller.ts new file mode 100644 index 0000000..50fdb12 --- /dev/null +++ b/src/modules/coupons/controllers/coupon.controller.ts @@ -0,0 +1,99 @@ +import { Controller, Get, Post, Body, Patch, Param, Delete, Query, UseGuards } from '@nestjs/common'; +import { CouponService } from '../providers/coupon.service'; +import { CreateCouponDto } from '../dto/create-coupon.dto'; +import { UpdateCouponDto } from '../dto/update-coupon.dto'; +import { FindCouponsDto } from '../dto/find-coupons.dto'; +import { ValidateCouponDto } from '../dto/validate-coupon.dto'; +import { + ApiTags, + ApiOperation, + ApiResponse, + ApiCreatedResponse, + ApiOkResponse, + ApiNotFoundResponse, + ApiQuery, + ApiBody, + ApiParam, + ApiBearerAuth, +} from '@nestjs/swagger'; +import { AdminAuthGuard } from 'src/modules/auth/guards/adminAuth.guard'; +import { RestId } from 'src/common/decorators'; + +@ApiTags('coupons') +@Controller() +export class CouponController { + constructor(private readonly couponService: CouponService) {} + + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Post('admin/coupons') + @ApiOperation({ summary: 'Create a new coupon' }) + @ApiCreatedResponse({ description: 'The coupon has been successfully created.', type: CreateCouponDto }) + @ApiBody({ type: CreateCouponDto }) + create(@Body() createCouponDto: CreateCouponDto, @RestId() restId: string) { + return this.couponService.create(restId, createCouponDto); + } + + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Get('admin/coupons') + @ApiOperation({ summary: 'Get a paginated list of coupons' }) + @ApiOkResponse({ description: 'Paginated list of coupons' }) + @ApiQuery({ name: 'page', required: false, type: Number }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ name: 'search', required: false, type: String }) + @ApiQuery({ name: 'orderBy', required: false, type: String }) + @ApiQuery({ name: 'order', required: false, enum: ['asc', 'desc'] }) + @ApiQuery({ name: 'type', required: false, enum: ['PERCENTAGE', 'FIXED'] }) + @ApiQuery({ name: 'isActive', required: false, type: Boolean }) + findAll(@Query() dto: FindCouponsDto, @RestId() restId: string) { + return this.couponService.findAll(restId, dto); + } + + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Get('admin/coupons/:id') + @ApiOperation({ summary: 'Get a coupon by id' }) + @ApiParam({ name: 'id', required: true }) + @ApiOkResponse({ description: 'The coupon', type: CreateCouponDto }) + @ApiNotFoundResponse({ description: 'Coupon not found' }) + findById(@Param('id') id: string) { + return this.couponService.findById(id); + } + + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Patch('admin/coupons/:id') + @ApiOperation({ summary: 'Update a coupon' }) + @ApiParam({ name: 'id', required: true }) + @ApiBody({ type: UpdateCouponDto }) + @ApiOkResponse({ description: 'The updated coupon', type: UpdateCouponDto }) + update(@Param('id') id: string, @Body() updateCouponDto: UpdateCouponDto) { + return this.couponService.update(id, updateCouponDto); + } + + @UseGuards(AdminAuthGuard) + @ApiBearerAuth() + @Delete('admin/coupons/:id') + @ApiOperation({ summary: 'Delete (soft) a coupon' }) + @ApiParam({ name: 'id', required: true }) + @ApiResponse({ status: 200, description: 'Coupon deleted' }) + remove(@Param('id') id: string) { + return this.couponService.remove(id); + } + + @Post('public/coupons/validate') + @ApiOperation({ summary: 'Validate a coupon code' }) + @ApiBody({ type: ValidateCouponDto }) + @ApiOkResponse({ description: 'Coupon validation result' }) + @ApiNotFoundResponse({ description: 'Coupon not found or invalid' }) + validateCoupon(@Body() validateCouponDto: ValidateCouponDto, @RestId() restId: string) { + return this.couponService.validateCoupon( + validateCouponDto.code, + restId, + validateCouponDto.orderAmount || 0, + validateCouponDto.userId, + ); + } +} + diff --git a/src/modules/coupons/coupon.module.ts b/src/modules/coupons/coupon.module.ts new file mode 100644 index 0000000..7bd3ff1 --- /dev/null +++ b/src/modules/coupons/coupon.module.ts @@ -0,0 +1,18 @@ +import { Module } from '@nestjs/common'; +import { CouponService } from './providers/coupon.service'; +import { CouponController } from './controllers/coupon.controller'; +import { CouponRepository } from './repositories/coupon.repository'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { Coupon } from './entities/coupon.entity'; +import { RestaurantsModule } from '../restaurants/restaurants.module'; +import { AuthModule } from '../auth/auth.module'; +import { JwtModule } from '@nestjs/jwt'; + +@Module({ + imports: [MikroOrmModule.forFeature([Coupon]), RestaurantsModule, AuthModule, JwtModule], + controllers: [CouponController], + providers: [CouponService, CouponRepository], + exports: [CouponRepository, CouponService], +}) +export class CouponModule {} + diff --git a/src/modules/coupons/dto/create-coupon.dto.ts b/src/modules/coupons/dto/create-coupon.dto.ts new file mode 100644 index 0000000..73afebe --- /dev/null +++ b/src/modules/coupons/dto/create-coupon.dto.ts @@ -0,0 +1,78 @@ +import { IsNotEmpty, IsString, IsEnum, IsNumber, IsOptional, IsBoolean, IsDateString, Min } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { CouponType } from '../entities/coupon.entity'; + +export class CreateCouponDto { + @IsNotEmpty() + @IsString() + @ApiProperty({ example: 'DISCOUNT10', description: 'Unique coupon code' }) + code!: string; + + @IsNotEmpty() + @IsString() + @ApiProperty({ example: '10% Off', description: 'Coupon name' }) + name!: string; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ example: 'Get 10% off on your order', description: 'Coupon description' }) + description?: string; + + @IsNotEmpty() + @IsEnum(CouponType) + @ApiProperty({ enum: CouponType, example: CouponType.PERCENTAGE, description: 'Coupon type' }) + type!: CouponType; + + @IsNotEmpty() + @IsNumber() + @Min(0) + @Type(() => Number) + @ApiProperty({ example: 10, description: 'Discount value (amount or percentage)' }) + value!: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Type(() => Number) + @ApiPropertyOptional({ example: 50000, description: 'Maximum discount amount (for percentage coupons)' }) + maxDiscount?: number; + + @IsOptional() + @IsNumber() + @Min(0) + @Type(() => Number) + @ApiPropertyOptional({ example: 100000, description: 'Minimum order amount to use coupon' }) + minOrderAmount?: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Type(() => Number) + @ApiPropertyOptional({ example: 100, description: 'Maximum number of times coupon can be used' }) + maxUses?: number; + + @IsOptional() + @IsNumber() + @Min(1) + @Type(() => Number) + @ApiPropertyOptional({ example: 1, description: 'Maximum uses per user' }) + maxUsesPerUser?: number; + + @IsOptional() + @IsDateString() + @ApiPropertyOptional({ example: '2024-01-01T00:00:00Z', description: 'Coupon validity start date' }) + startDate?: string; + + @IsOptional() + @IsDateString() + @ApiPropertyOptional({ example: '2024-12-31T23:59:59Z', description: 'Coupon validity end date' }) + endDate?: string; + + @IsOptional() + @IsBoolean() + @Type(() => Boolean) + @ApiPropertyOptional({ example: true, description: 'Whether coupon is active' }) + isActive?: boolean; +} + diff --git a/src/modules/coupons/dto/find-coupons.dto.ts b/src/modules/coupons/dto/find-coupons.dto.ts new file mode 100644 index 0000000..4f7c707 --- /dev/null +++ b/src/modules/coupons/dto/find-coupons.dto.ts @@ -0,0 +1,45 @@ +import { IsOptional, IsString, IsBoolean, IsNumber, IsEnum } from 'class-validator'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { CouponType } from '../entities/coupon.entity'; + +export class FindCouponsDto { + @IsOptional() + @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ example: 1, description: 'Page number' }) + page?: number; + + @IsOptional() + @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ example: 10, description: 'Items per page' }) + limit?: number; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ example: 'DISCOUNT', description: 'Search term for code or name' }) + search?: string; + + @IsOptional() + @IsEnum(CouponType) + @ApiPropertyOptional({ enum: CouponType, description: 'Filter by coupon type' }) + type?: CouponType; + + @IsOptional() + @IsBoolean() + @Type(() => Boolean) + @ApiPropertyOptional({ example: true, description: 'Filter by active status' }) + isActive?: boolean; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ example: 'createdAt', description: 'Field to order by' }) + orderBy?: string; + + @IsOptional() + @IsEnum(['asc', 'desc']) + @ApiPropertyOptional({ enum: ['asc', 'desc'], example: 'desc', description: 'Sort order' }) + order?: 'asc' | 'desc'; +} + diff --git a/src/modules/coupons/dto/update-coupon.dto.ts b/src/modules/coupons/dto/update-coupon.dto.ts new file mode 100644 index 0000000..dab8bf0 --- /dev/null +++ b/src/modules/coupons/dto/update-coupon.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateCouponDto } from './create-coupon.dto'; + +export class UpdateCouponDto extends PartialType(CreateCouponDto) {} + diff --git a/src/modules/coupons/dto/validate-coupon.dto.ts b/src/modules/coupons/dto/validate-coupon.dto.ts new file mode 100644 index 0000000..54fd225 --- /dev/null +++ b/src/modules/coupons/dto/validate-coupon.dto.ts @@ -0,0 +1,22 @@ +import { IsNotEmpty, IsString, IsNumber, IsOptional } from 'class-validator'; +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; + +export class ValidateCouponDto { + @IsNotEmpty() + @IsString() + @ApiProperty({ example: 'DISCOUNT10', description: 'Coupon code to validate' }) + code!: string; + + @IsOptional() + @IsNumber() + @Type(() => Number) + @ApiPropertyOptional({ example: 150000, description: 'Order total amount for validation' }) + orderAmount?: number; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ example: 'user-id', description: 'User ID for per-user usage limits' }) + userId?: string; +} + diff --git a/src/modules/coupons/entities/coupon.entity.ts b/src/modules/coupons/entities/coupon.entity.ts new file mode 100644 index 0000000..08c1e1d --- /dev/null +++ b/src/modules/coupons/entities/coupon.entity.ts @@ -0,0 +1,53 @@ +import { Entity, ManyToOne, Property, Enum } from '@mikro-orm/core'; +import { BaseEntity } from '../../../common/entities/base.entity'; +import { Restaurant } from '../../restaurants/entities/restaurant.entity'; + +export enum CouponType { + PERCENTAGE = 'PERCENTAGE', + FIXED = 'FIXED', +} + +@Entity({ tableName: 'coupons' }) +export class Coupon extends BaseEntity { + @ManyToOne(() => Restaurant) + restaurant!: Restaurant; + + @Property({ unique: true }) + code!: string; + + @Property() + name!: string; + + @Property({ type: 'text', nullable: true }) + description?: string; + + @Enum(() => CouponType) + type!: CouponType; + + @Property({ type: 'decimal', precision: 10, scale: 2 }) + value!: number; // Discount amount or percentage + + @Property({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + maxDiscount?: number; // Maximum discount for percentage coupons + + @Property({ type: 'decimal', precision: 10, scale: 2, nullable: true }) + minOrderAmount?: number; // Minimum order amount to use coupon + + @Property({ type: 'int', nullable: true }) + maxUses?: number; // Maximum number of times coupon can be used + + @Property({ type: 'int', default: 0 }) + usedCount: number = 0; // Number of times coupon has been used + + @Property({ type: 'int', nullable: true }) + maxUsesPerUser?: number; // Maximum uses per user + + @Property({ type: 'timestamptz', nullable: true }) + startDate?: Date; // Coupon validity start date + + @Property({ type: 'timestamptz', nullable: true }) + endDate?: Date; // Coupon validity end date + + @Property({ type: 'boolean', default: true }) + isActive: boolean = true; +} diff --git a/src/modules/coupons/providers/coupon.service.ts b/src/modules/coupons/providers/coupon.service.ts new file mode 100644 index 0000000..5f4a351 --- /dev/null +++ b/src/modules/coupons/providers/coupon.service.ts @@ -0,0 +1,253 @@ +import { Injectable, NotFoundException, BadRequestException } from '@nestjs/common'; +import { CreateCouponDto } from '../dto/create-coupon.dto'; +import { UpdateCouponDto } from '../dto/update-coupon.dto'; +import { FindCouponsDto } from '../dto/find-coupons.dto'; +import { CouponRepository } from '../repositories/coupon.repository'; +import { RestRepository } from '../../restaurants/repositories/rest.repository'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { RequiredEntityData } from '@mikro-orm/core'; +import { Coupon, CouponType } from '../entities/coupon.entity'; +import { CouponMessage, RestMessage } from 'src/common/enums/message.enum'; + +@Injectable() +export class CouponService { + constructor( + private readonly couponRepository: CouponRepository, + private readonly restRepository: RestRepository, + private readonly em: EntityManager, + ) {} + + async create(restId: string, createCouponDto: CreateCouponDto): Promise { + const restaurant = await this.restRepository.findOne({ id: restId }); + if (!restaurant) { + throw new NotFoundException(RestMessage.NOT_FOUND); + } + + // Check if code already exists + const existingCoupon = await this.couponRepository.findOne({ code: createCouponDto.code }); + if (existingCoupon) { + throw new BadRequestException(CouponMessage.CODE_ALREADY_EXISTS); + } + + // Validate dates + if (createCouponDto.startDate && createCouponDto.endDate) { + const startDate = new Date(createCouponDto.startDate); + const endDate = new Date(createCouponDto.endDate); + if (endDate <= startDate) { + throw new BadRequestException(CouponMessage.END_DATE_MUST_BE_AFTER_START_DATE); + } + } + + // Validate percentage coupon + if (createCouponDto.type === CouponType.PERCENTAGE) { + if (createCouponDto.value < 0 || createCouponDto.value > 100) { + throw new BadRequestException(CouponMessage.PERCENTAGE_MUST_BE_BETWEEN_0_AND_100); + } + } + + const data: RequiredEntityData = { + restaurant, + code: createCouponDto.code, + name: createCouponDto.name, + description: createCouponDto.description, + type: createCouponDto.type, + value: createCouponDto.value, + maxDiscount: createCouponDto.maxDiscount, + minOrderAmount: createCouponDto.minOrderAmount, + maxUses: createCouponDto.maxUses, + maxUsesPerUser: createCouponDto.maxUsesPerUser, + startDate: createCouponDto.startDate ? new Date(createCouponDto.startDate) : undefined, + endDate: createCouponDto.endDate ? new Date(createCouponDto.endDate) : undefined, + isActive: createCouponDto.isActive ?? true, + usedCount: 0, + }; + + const coupon = this.couponRepository.create(data); + if (!coupon) { + throw new Error('Failed to create coupon entity'); + } + + await this.em.persistAndFlush(coupon); + return coupon; + } + + findAll(restId: string, dto: FindCouponsDto) { + return this.couponRepository.findAllPaginated(restId, dto); + } + + async findById(id: string): Promise { + const coupon = await this.couponRepository.findOne({ id }, { populate: ['restaurant'] }); + if (!coupon) { + throw new NotFoundException(CouponMessage.NOT_FOUND); + } + return coupon; + } + + async findByCode(code: string, restId: string): Promise { + const coupon = await this.couponRepository.findOne({ code, restaurant: { id: restId } }); + if (!coupon) { + throw new NotFoundException(CouponMessage.NOT_FOUND); + } + return coupon; + } + + async update(id: string, dto: UpdateCouponDto): Promise { + const coupon = await this.couponRepository.findOne({ id }); + if (!coupon) { + throw new NotFoundException(CouponMessage.NOT_FOUND); + } + + // Check if code is being changed and if it already exists + if (dto.code && dto.code !== coupon.code) { + const existingCoupon = await this.couponRepository.findOne({ code: dto.code }); + if (existingCoupon) { + throw new BadRequestException(CouponMessage.CODE_ALREADY_EXISTS); + } + } + + // Validate dates + const startDate = dto.startDate ? new Date(dto.startDate) : coupon.startDate; + const endDate = dto.endDate ? new Date(dto.endDate) : coupon.endDate; + if (startDate && endDate && endDate <= startDate) { + throw new BadRequestException(CouponMessage.END_DATE_MUST_BE_AFTER_START_DATE); + } + + // Validate percentage coupon + if (dto.type === CouponType.PERCENTAGE || coupon.type === CouponType.PERCENTAGE) { + const value = dto.value ?? coupon.value; + if (value < 0 || value > 100) { + throw new BadRequestException(CouponMessage.PERCENTAGE_MUST_BE_BETWEEN_0_AND_100); + } + } + + // Update fields + if (dto.code) coupon.code = dto.code; + if (dto.name) coupon.name = dto.name; + if (dto.description !== undefined) coupon.description = dto.description; + if (dto.type) coupon.type = dto.type; + if (dto.value !== undefined) coupon.value = dto.value; + if (dto.maxDiscount !== undefined) coupon.maxDiscount = dto.maxDiscount; + if (dto.minOrderAmount !== undefined) coupon.minOrderAmount = dto.minOrderAmount; + if (dto.maxUses !== undefined) coupon.maxUses = dto.maxUses; + if (dto.maxUsesPerUser !== undefined) coupon.maxUsesPerUser = dto.maxUsesPerUser; + if (dto.startDate !== undefined) coupon.startDate = dto.startDate ? new Date(dto.startDate) : undefined; + if (dto.endDate !== undefined) coupon.endDate = dto.endDate ? new Date(dto.endDate) : undefined; + if (dto.isActive !== undefined) coupon.isActive = dto.isActive; + + await this.em.persistAndFlush(coupon); + return coupon; + } + + async remove(id: string) { + const coupon = await this.couponRepository.findOne({ id }); + if (!coupon) { + throw new NotFoundException(CouponMessage.NOT_FOUND); + } + coupon.deletedAt = new Date(); + await this.em.persistAndFlush(coupon); + } + + /** + * Validate and calculate discount for a coupon + */ + async validateCoupon( + code: string, + restId: string, + orderAmount: number = 0, + userId?: string, + ): Promise<{ + valid: boolean; + coupon?: Coupon; + discount: number; + message?: string; + }> { + const coupon = await this.couponRepository.findOne( + { code, restaurant: { id: restId } }, + { populate: ['restaurant'] }, + ); + + if (!coupon) { + return { + valid: false, + discount: 0, + message: CouponMessage.NOT_FOUND, + }; + } + + // Check if coupon is active + if (!coupon.isActive) { + return { + valid: false, + discount: 0, + message: CouponMessage.COUPON_INACTIVE, + }; + } + + // Check date validity + const now = new Date(); + if (coupon.startDate && now < coupon.startDate) { + return { + valid: false, + discount: 0, + message: CouponMessage.COUPON_NOT_STARTED, + }; + } + + if (coupon.endDate && now > coupon.endDate) { + return { + valid: false, + discount: 0, + message: CouponMessage.COUPON_EXPIRED, + }; + } + + // Check max uses + if (coupon.maxUses && coupon.usedCount >= coupon.maxUses) { + return { + valid: false, + discount: 0, + message: CouponMessage.COUPON_MAX_USES_REACHED, + }; + } + + // Check minimum order amount + if (coupon.minOrderAmount && orderAmount < coupon.minOrderAmount) { + return { + valid: false, + discount: 0, + message: CouponMessage.MIN_ORDER_AMOUNT_NOT_MET, + }; + } + + // Calculate discount + let discount = 0; + if (coupon.type === CouponType.PERCENTAGE) { + discount = (orderAmount * coupon.value) / 100; + if (coupon.maxDiscount && discount > coupon.maxDiscount) { + discount = coupon.maxDiscount; + } + } else if (coupon.type === CouponType.FIXED) { + discount = coupon.value; + if (discount > orderAmount) { + discount = orderAmount; + } + } + + return { + valid: true, + coupon, + discount: Math.round(discount), + }; + } + + /** + * Increment coupon usage count + */ + async incrementUsage(id: string): Promise { + const coupon = await this.couponRepository.findOne({ id }); + if (coupon) { + coupon.usedCount += 1; + await this.em.persistAndFlush(coupon); + } + } +} diff --git a/src/modules/coupons/repositories/coupon.repository.ts b/src/modules/coupons/repositories/coupon.repository.ts new file mode 100644 index 0000000..c34ffb6 --- /dev/null +++ b/src/modules/coupons/repositories/coupon.repository.ts @@ -0,0 +1,66 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { FilterQuery } from '@mikro-orm/core'; +import { Coupon } from '../entities/coupon.entity'; +import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; + +type FindCouponsOpts = { + page?: number; + limit?: number; + search?: string; + orderBy?: string; + order?: 'asc' | 'desc'; + type?: string; + isActive?: boolean; +}; + +@Injectable() +export class CouponRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, Coupon); + } + + /** + * Find coupons with pagination and optional filters. + * Supports: search (code/name), type, isActive, ordering. + */ + async findAllPaginated(restId: string, opts: FindCouponsOpts = {}): Promise> { + const { page = 1, limit = 10, search, orderBy = 'createdAt', order = 'desc', type, isActive } = opts; + + const offset = (page - 1) * limit; + + const where: FilterQuery = { restaurant: { id: restId } }; + + if (typeof isActive === 'boolean') { + where.isActive = isActive; + } + + if (type) { + where.type = type; + } + + if (search) { + const pattern = `%${search}%`; + where.$or = [{ code: { $ilike: pattern } }, { name: { $ilike: pattern } }]; + } + + const [data, total] = await this.findAndCount(where, { + limit, + offset, + orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, + }); + + const totalPages = Math.ceil(total / limit); + + return { + data, + meta: { + total, + page, + limit, + totalPages, + }, + }; + } +} + diff --git a/src/modules/orders/entities/order.entity.ts b/src/modules/orders/entities/order.entity.ts index 3ca0844..399ac0c 100644 --- a/src/modules/orders/entities/order.entity.ts +++ b/src/modules/orders/entities/order.entity.ts @@ -50,7 +50,7 @@ export class Order extends BaseEntity { @Property({ type: 'decimal', precision: 10, scale: 0, default: 0 }) couponDiscount: number = 0; - @Property({ type: 'json', nullable: true }) + @Property({ type: 'jsonb', nullable: true }) couponDetail: { couponId: string; couponName: string; diff --git a/src/seeders/DatabaseSeeder.ts b/src/seeders/DatabaseSeeder.ts index b6ba43f..7b043d9 100644 --- a/src/seeders/DatabaseSeeder.ts +++ b/src/seeders/DatabaseSeeder.ts @@ -11,6 +11,7 @@ import { CategoriesSeeder } from './categories.seeder'; import { FoodsSeeder } from './foods.seeder'; import { AdminsSeeder } from './admins.seeder'; import { UsersSeeder } from './users.seeder'; +import { CouponsSeeder } from './coupons.seeder'; export class DatabaseSeeder extends Seeder { async run(em: EntityManager) { @@ -47,11 +48,15 @@ export class DatabaseSeeder extends Seeder { const foodsSeeder = new FoodsSeeder(); await foodsSeeder.run(em, restaurantsMap, categoriesMap); - // 9. Create Admins + // 9. Create Coupons + const couponsSeeder = new CouponsSeeder(); + await couponsSeeder.run(em, restaurantsMap); + + // 10. Create Admins const adminsSeeder = new AdminsSeeder(); await adminsSeeder.run(em, rolesMap, restaurantsMap); - // 10. Create Users + // 11. Create Users const usersSeeder = new UsersSeeder(); await usersSeeder.run(em); } diff --git a/src/seeders/coupons.seeder.ts b/src/seeders/coupons.seeder.ts new file mode 100644 index 0000000..a1136ff --- /dev/null +++ b/src/seeders/coupons.seeder.ts @@ -0,0 +1,43 @@ +import type { EntityManager } from '@mikro-orm/core'; +import { Coupon } from '../modules/coupons/entities/coupon.entity'; +import { Restaurant } from '../modules/restaurants/entities/restaurant.entity'; +import { couponsData } from './data/coupons.data'; + +export class CouponsSeeder { + async run(em: EntityManager, restaurantsMap: Map): Promise { + for (const couponData of couponsData) { + const restaurant = restaurantsMap.get(couponData.restaurantSlug); + + if (!restaurant) continue; + + const existing = await em.findOne(Coupon, { + code: couponData.code, + restaurant: { id: restaurant.id }, + }); + + if (!existing) { + const coupon = em.create(Coupon, { + restaurant, + code: couponData.code, + name: couponData.name, + description: couponData.description, + type: couponData.type, + value: couponData.value, + maxDiscount: couponData.maxDiscount, + minOrderAmount: couponData.minOrderAmount, + maxUses: couponData.maxUses, + maxUsesPerUser: couponData.maxUsesPerUser, + startDate: couponData.startDate ? new Date(couponData.startDate) : undefined, + endDate: couponData.endDate ? new Date(couponData.endDate) : undefined, + isActive: couponData.isActive, + usedCount: 0, + }); + + em.persist(coupon); + } + } + + await em.flush(); + } +} + diff --git a/src/seeders/data/coupons.data.ts b/src/seeders/data/coupons.data.ts new file mode 100644 index 0000000..9926105 --- /dev/null +++ b/src/seeders/data/coupons.data.ts @@ -0,0 +1,95 @@ +import { CouponType } from '../../modules/coupons/entities/coupon.entity'; + +export interface CouponData { + restaurantSlug: string; + code: string; + name: string; + description?: string; + type: CouponType; + value: number; + maxDiscount?: number; + minOrderAmount?: number; + maxUses?: number; + maxUsesPerUser?: number; + startDate?: string; + endDate?: string; + isActive: boolean; +} + +export const couponsData: CouponData[] = [ + { + restaurantSlug: 'zhivan', + code: 'WELCOME10', + name: 'خوش آمدگویی ۱۰٪', + description: 'تخفیف ۱۰ درصدی برای اولین خرید', + type: CouponType.PERCENTAGE, + value: 10, + maxDiscount: 50000, + minOrderAmount: 100000, + maxUses: 100, + maxUsesPerUser: 1, + isActive: true, + }, + { + restaurantSlug: 'zhivan', + code: 'FIXED50K', + name: 'تخفیف ۵۰ هزار تومانی', + description: 'تخفیف ثابت ۵۰ هزار تومانی', + type: CouponType.FIXED, + value: 50000, + minOrderAmount: 200000, + maxUses: 50, + maxUsesPerUser: 2, + isActive: true, + }, + { + restaurantSlug: 'zhivan', + code: 'WEEKEND20', + name: 'تعطیلات ۲۰٪', + description: 'تخفیف ۲۰ درصدی برای آخر هفته', + type: CouponType.PERCENTAGE, + value: 20, + maxDiscount: 100000, + minOrderAmount: 150000, + maxUses: 200, + isActive: true, + }, + { + restaurantSlug: 'boote', + code: 'FIRST15', + name: 'اولین خرید ۱۵٪', + description: 'تخفیف ۱۵ درصدی برای اولین خرید', + type: CouponType.PERCENTAGE, + value: 15, + maxDiscount: 75000, + minOrderAmount: 100000, + maxUses: 100, + maxUsesPerUser: 1, + isActive: true, + }, + { + restaurantSlug: 'boote', + code: 'SAVE30K', + name: 'صرفه جویی ۳۰ هزار', + description: 'تخفیف ثابت ۳۰ هزار تومانی', + type: CouponType.FIXED, + value: 30000, + minOrderAmount: 100000, + maxUses: 100, + isActive: true, + }, + { + restaurantSlug: 'boote', + code: 'VIP25', + name: 'ویژه ۲۵٪', + description: 'تخفیف ویژه ۲۵ درصدی', + type: CouponType.PERCENTAGE, + value: 25, + maxDiscount: 150000, + minOrderAmount: 200000, + maxUses: 50, + maxUsesPerUser: 1, + isActive: true, + }, +]; +