272 lines
8.9 KiB
TypeScript
272 lines
8.9 KiB
TypeScript
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 } from '../entities/coupon.entity';
|
|
import { CouponType } from '../interface/coupon';
|
|
import { CouponMessage, RestMessage } from 'src/common/enums/message.enum';
|
|
import { randomBytes } from 'node:crypto';
|
|
|
|
@Injectable()
|
|
export class CouponService {
|
|
constructor(
|
|
private readonly couponRepository: CouponRepository,
|
|
private readonly restRepository: RestRepository,
|
|
private readonly em: EntityManager,
|
|
) {}
|
|
|
|
async create(restId: string, createCouponDto: CreateCouponDto): Promise<Coupon> {
|
|
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<Coupon> = {
|
|
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,
|
|
foodCategories: createCouponDto.foodCategories,
|
|
foods: createCouponDto.foods,
|
|
userPhone: createCouponDto.userPhone,
|
|
};
|
|
|
|
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<Coupon> {
|
|
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<Coupon> {
|
|
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<Coupon> {
|
|
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;
|
|
if (dto.foodCategories !== undefined) coupon.foodCategories = dto.foodCategories;
|
|
if (dto.foods !== undefined) coupon.foods = dto.foods;
|
|
if (dto.userPhone !== undefined) coupon.userPhone = dto.userPhone;
|
|
|
|
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 coupon
|
|
*/
|
|
async validateCoupon(
|
|
code: string,
|
|
restId: string,
|
|
orderAmount: number,
|
|
userPhone: string,
|
|
): Promise<{
|
|
valid: boolean;
|
|
coupon?: Coupon;
|
|
message?: string;
|
|
}> {
|
|
const coupon = await this.couponRepository.findOne(
|
|
{ code, restaurant: { id: restId } },
|
|
{ populate: ['restaurant'] },
|
|
);
|
|
|
|
if (!coupon) {
|
|
return {
|
|
valid: false,
|
|
message: CouponMessage.NOT_FOUND,
|
|
};
|
|
}
|
|
|
|
if (coupon.userPhone) {
|
|
if (userPhone !== coupon.userPhone) {
|
|
return {
|
|
valid: false,
|
|
message: CouponMessage.COUPON_RESTRICTED_TO_USER,
|
|
};
|
|
}
|
|
}
|
|
// Check if coupon is active
|
|
if (!coupon.isActive) {
|
|
return {
|
|
valid: false,
|
|
message: CouponMessage.COUPON_INACTIVE,
|
|
};
|
|
}
|
|
|
|
// Check date validity
|
|
const now = new Date();
|
|
if (coupon.startDate && now < coupon.startDate) {
|
|
return {
|
|
valid: false,
|
|
message: CouponMessage.COUPON_NOT_STARTED,
|
|
};
|
|
}
|
|
|
|
if (coupon.endDate && now > coupon.endDate) {
|
|
return {
|
|
valid: false,
|
|
message: CouponMessage.COUPON_EXPIRED,
|
|
};
|
|
}
|
|
|
|
// Check max uses
|
|
if (coupon.maxUses && coupon.usedCount >= coupon.maxUses) {
|
|
return {
|
|
valid: false,
|
|
message: CouponMessage.COUPON_MAX_USES_REACHED,
|
|
};
|
|
}
|
|
|
|
// Check minimum order amount
|
|
if (coupon.minOrderAmount && orderAmount < coupon.minOrderAmount) {
|
|
return {
|
|
valid: false,
|
|
message: CouponMessage.MIN_ORDER_AMOUNT_NOT_MET,
|
|
};
|
|
}
|
|
|
|
return {
|
|
valid: true,
|
|
coupon,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Increment coupon usage count
|
|
*/
|
|
async incrementUsage(id: string): Promise<void> {
|
|
const coupon = await this.couponRepository.findOne({ id });
|
|
if (coupon) {
|
|
coupon.usedCount += 1;
|
|
await this.em.persistAndFlush(coupon);
|
|
}
|
|
}
|
|
|
|
generateShortCode(restaurantSlug: string, length = 5) {
|
|
const chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789';
|
|
const bytes = randomBytes(length);
|
|
let result = '';
|
|
|
|
for (let i = 0; i < bytes.length; i++) {
|
|
result += chars[bytes[i] % chars.length];
|
|
}
|
|
const prefix = restaurantSlug.slice(0, 2).toUpperCase();
|
|
return prefix + '-' + result;
|
|
}
|
|
|
|
async generateCouponCode(restId: string){
|
|
const restaurant = await this.restRepository.findOne({ id: restId });
|
|
if (!restaurant) {
|
|
throw new NotFoundException(RestMessage.NOT_FOUND);
|
|
}
|
|
const code = this.generateShortCode(restaurant.slug);
|
|
const existing = await this.couponRepository.findOne({ code, restaurant: { id: restId } });
|
|
|
|
if (existing) return this.generateCouponCode(restId);
|
|
return {code};
|
|
}
|
|
}
|