From 74815cb05ca4f7885aeaebf53b396c8578176736 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Wed, 15 Jul 2026 20:41:04 +0330 Subject: [PATCH] AddServiceLoyalty --- .../1789047200000-AddServiceLoyalty.ts | 53 +++++ src/entities/booking.entity.ts | 4 + src/entities/business-service.entity.ts | 8 + .../customer-service-loyalty.entity.ts | 52 +++++ src/modules/bookings/bookings.controller.ts | 10 + src/modules/bookings/bookings.module.ts | 3 + src/modules/bookings/bookings.service.ts | 213 ++++++++++++++++-- .../business-services.service.ts | 14 ++ .../dto/update-business-service.dto.ts | 21 ++ 9 files changed, 364 insertions(+), 14 deletions(-) create mode 100644 src/database/migrations/1789047200000-AddServiceLoyalty.ts create mode 100644 src/entities/customer-service-loyalty.entity.ts diff --git a/src/database/migrations/1789047200000-AddServiceLoyalty.ts b/src/database/migrations/1789047200000-AddServiceLoyalty.ts new file mode 100644 index 0000000..5e8ce91 --- /dev/null +++ b/src/database/migrations/1789047200000-AddServiceLoyalty.ts @@ -0,0 +1,53 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; + +export class AddServiceLoyalty1789047200000 implements MigrationInterface { + name = 'AddServiceLoyalty1789047200000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TABLE "business_services" + ADD COLUMN IF NOT EXISTS "loyaltyEnabled" boolean NOT NULL DEFAULT false + `); + await queryRunner.query(` + ALTER TABLE "business_services" + ADD COLUMN IF NOT EXISTS "loyaltyEveryN" integer + `); + + await queryRunner.query(` + ALTER TABLE "bookings" + ADD COLUMN IF NOT EXISTS "loyaltyRedeemed" boolean NOT NULL DEFAULT false + `); + + await queryRunner.query(` + CREATE TABLE IF NOT EXISTS "customer_service_loyalty" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid(), + "customerId" uuid NOT NULL, + "businessServiceId" uuid NOT NULL, + "paidCount" integer NOT NULL DEFAULT 0, + "reservedBookingId" uuid, + "createdAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + "updatedAt" TIMESTAMPTZ NOT NULL DEFAULT now(), + CONSTRAINT "UQ_customer_service_loyalty" UNIQUE ("customerId", "businessServiceId"), + CONSTRAINT "FK_loyalty_customer" + FOREIGN KEY ("customerId") REFERENCES "users"("id") ON DELETE CASCADE, + CONSTRAINT "FK_loyalty_business_service" + FOREIGN KEY ("businessServiceId") REFERENCES "business_services"("id") ON DELETE CASCADE, + CONSTRAINT "FK_loyalty_reserved_booking" + FOREIGN KEY ("reservedBookingId") REFERENCES "bookings"("id") ON DELETE SET NULL + ) + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query(`DROP TABLE IF EXISTS "customer_service_loyalty"`); + await queryRunner.query(` + ALTER TABLE "bookings" DROP COLUMN IF EXISTS "loyaltyRedeemed" + `); + await queryRunner.query(` + ALTER TABLE "business_services" DROP COLUMN IF EXISTS "loyaltyEveryN" + `); + await queryRunner.query(` + ALTER TABLE "business_services" DROP COLUMN IF EXISTS "loyaltyEnabled" + `); + } +} diff --git a/src/entities/booking.entity.ts b/src/entities/booking.entity.ts index 33797c6..4ef6732 100644 --- a/src/entities/booking.entity.ts +++ b/src/entities/booking.entity.ts @@ -86,6 +86,10 @@ export class BookingEntity { @Column({ type: 'timestamptz', nullable: true }) reviewPromptedAt!: Date | null; + /** True when this booking used a loyalty free redeem for its service. */ + @Column({ default: false }) + loyaltyRedeemed!: boolean; + @CreateDateColumn() createdAt!: Date; diff --git a/src/entities/business-service.entity.ts b/src/entities/business-service.entity.ts index 4fbad31..dd19ba9 100644 --- a/src/entities/business-service.entity.ts +++ b/src/entities/business-service.entity.ts @@ -47,6 +47,14 @@ export class BusinessServiceEntity { @Column({ default: true }) isActive!: boolean; + /** When true, customers earn a free visit of this service after N paid completions. */ + @Column({ default: false }) + loyaltyEnabled!: boolean; + + /** Number of paid completions required before the next visit is free (e.g. 3 or 5). */ + @Column({ type: 'int', nullable: true }) + loyaltyEveryN!: number | null; + @OneToMany(() => BookingEntity, (booking) => booking.businessService) bookings!: BookingEntity[]; diff --git a/src/entities/customer-service-loyalty.entity.ts b/src/entities/customer-service-loyalty.entity.ts new file mode 100644 index 0000000..e7b2598 --- /dev/null +++ b/src/entities/customer-service-loyalty.entity.ts @@ -0,0 +1,52 @@ +import { + Column, + CreateDateColumn, + Entity, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + Unique, + UpdateDateColumn, +} from 'typeorm'; +import { BookingEntity } from './booking.entity'; +import { BusinessServiceEntity } from './business-service.entity'; +import { UserEntity } from './user.entity'; + +@Entity('customer_service_loyalty') +@Unique('UQ_customer_service_loyalty', ['customerId', 'businessServiceId']) +export class CustomerServiceLoyaltyEntity { + @PrimaryGeneratedColumn('uuid') + id!: string; + + @ManyToOne(() => UserEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'customerId' }) + customer!: UserEntity; + + @Column('uuid') + customerId!: string; + + @ManyToOne(() => BusinessServiceEntity, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'businessServiceId' }) + businessService!: BusinessServiceEntity; + + @Column('uuid') + businessServiceId!: string; + + /** Number of paid completed visits toward the next free service. */ + @Column({ type: 'int', default: 0 }) + paidCount!: number; + + /** Booking currently holding a reserved free redeem (awaiting payment / confirmed). */ + @ManyToOne(() => BookingEntity, { nullable: true, onDelete: 'SET NULL' }) + @JoinColumn({ name: 'reservedBookingId' }) + reservedBooking!: BookingEntity | null; + + @Column({ type: 'uuid', nullable: true }) + reservedBookingId!: string | null; + + @CreateDateColumn() + createdAt!: Date; + + @UpdateDateColumn() + updatedAt!: Date; +} diff --git a/src/modules/bookings/bookings.controller.ts b/src/modules/bookings/bookings.controller.ts index 80faeec..d129883 100644 --- a/src/modules/bookings/bookings.controller.ts +++ b/src/modules/bookings/bookings.controller.ts @@ -89,6 +89,16 @@ export class BookingsController { return this.service.getPendingReviews(user); } + @Get('loyalty/:businessServiceId') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.CUSTOMER) + getLoyaltyStatus( + @Param('businessServiceId') businessServiceId: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.getLoyaltyStatus(businessServiceId, user); + } + @Get('tip/callback') @Redirect() async tipCallback( diff --git a/src/modules/bookings/bookings.module.ts b/src/modules/bookings/bookings.module.ts index 77f18f9..28c45cb 100644 --- a/src/modules/bookings/bookings.module.ts +++ b/src/modules/bookings/bookings.module.ts @@ -7,6 +7,7 @@ import { BusinessServiceClassPriceEntity } from '../../entities/business-service import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity'; import { BusinessServiceEntity } from '../../entities/business-service.entity'; import { BusinessEntity } from '../../entities/business.entity'; +import { CustomerServiceLoyaltyEntity } from '../../entities/customer-service-loyalty.entity'; import { ServiceTemplateEntity } from '../../entities/service-template.entity'; import { UserEntity } from '../../entities/user.entity'; import { AuthModule } from '../auth/auth.module'; @@ -25,6 +26,7 @@ import { BookingsService } from './bookings.service'; BusinessAvailabilitySlotEntity, BusinessServiceClassEntity, BusinessServiceClassPriceEntity, + CustomerServiceLoyaltyEntity, ServiceTemplateEntity, ]), AuthModule, @@ -32,5 +34,6 @@ import { BookingsService } from './bookings.service'; ], controllers: [BookingsController], providers: [BookingsService], + exports: [BookingsService], }) export class BookingsModule {} diff --git a/src/modules/bookings/bookings.service.ts b/src/modules/bookings/bookings.service.ts index fff435e..99a9c9e 100644 --- a/src/modules/bookings/bookings.service.ts +++ b/src/modules/bookings/bookings.service.ts @@ -24,6 +24,7 @@ import { BusinessServiceClassPriceEntity } from '../../entities/business-service import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity'; import { BusinessServiceEntity } from '../../entities/business-service.entity'; import { BusinessEntity } from '../../entities/business.entity'; +import { CustomerServiceLoyaltyEntity } from '../../entities/customer-service-loyalty.entity'; import { UserEntity, UserRole } from '../../entities/user.entity'; import { AuthUser } from '../auth/auth.types'; import { SmsService } from '../auth/sms.service'; @@ -66,6 +67,8 @@ export class BookingsService { private readonly classPriceRepo: Repository, @InjectRepository(BookingReviewEntity) private readonly reviewRepo: Repository, + @InjectRepository(CustomerServiceLoyaltyEntity) + private readonly loyaltyRepo: Repository, ) { const raw = this.config.get('BOOKING_MIN_LEAD_MINUTES'); const parsed = raw !== undefined ? Number.parseInt(String(raw), 10) : NaN; @@ -349,14 +352,25 @@ export class BookingsService { this.applyStatusTransition(row, BookingStatus.COMPLETED, actor); const now = new Date(); row.completedAt = now; + + let completedRows: BookingEntity[] = [row]; if (row.bookingGroupId) { await this.repo.update( { bookingGroupId: row.bookingGroupId }, { status: BookingStatus.COMPLETED, completedAt: now }, ); + completedRows = await this.repo.find({ + where: { bookingGroupId: row.bookingGroupId }, + relations: { businessService: true }, + }); } else { await this.repo.save(row); } + + for (const completedRow of completedRows) { + await this.applyLoyaltyOnComplete(completedRow); + } + const completed = await this.findOne(id, actor); await this.promptReviewIfNeeded(completed); return completed; @@ -379,7 +393,59 @@ export class BookingsService { } this.applyStatusTransition(row, BookingStatus.CANCELLED, actor); - return this.repo.save(row); + const saved = await this.repo.save(row); + await this.releaseLoyaltyReservation(saved); + return saved; + } + + async getLoyaltyStatus( + businessServiceId: string, + actor: AuthUser, + ): Promise<{ + enabled: boolean; + everyN: number | null; + paidCount: number; + nextIsFree: boolean; + remainingPaidVisits: number | null; + }> { + const service = await this.businessServiceRepo.findOne({ + where: { id: businessServiceId }, + }); + if (!service) { + throw new NotFoundException('Business service not found'); + } + + const everyN = service.loyaltyEveryN; + const enabled = service.loyaltyEnabled === true && !!everyN && everyN >= 2; + if (!enabled) { + return { + enabled: false, + everyN: everyN ?? null, + paidCount: 0, + nextIsFree: false, + remainingPaidVisits: null, + }; + } + + const loyalty = await this.loyaltyRepo.findOne({ + where: { customerId: actor.id, businessServiceId }, + }); + const paidCount = loyalty?.paidCount ?? 0; + const reservedElsewhere = + !!loyalty?.reservedBookingId && + loyalty.reservedBookingId.length > 0; + const nextIsFree = paidCount >= everyN && !reservedElsewhere; + const remainingPaidVisits = nextIsFree + ? 0 + : Math.max(everyN - paidCount, 0); + + return { + enabled: true, + everyN, + paidCount, + nextIsFree, + remainingPaidVisits, + }; } async findByPaymentToken( @@ -424,6 +490,12 @@ export class BookingsService { const group = await this.loadPaymentGroup(row); const amountToman = await this.resolveGroupAmountToman(group); const leader = group[0]!; + + if (amountToman <= 0) { + await this.completePaymentGroup(group, `free-${Date.now()}`); + return { startUrl: '', amountToman: 0, mock: true }; + } + const description = `رزرو ${leader.business?.title ?? 'منتایم'}`; const payment = await this.zarinpal.requestPayment({ @@ -438,8 +510,8 @@ export class BookingsService { return { startUrl: '', amountToman, mock: true }; } - for (const row of group) { - row.paymentAuthority = payment.authority; + for (const item of group) { + item.paymentAuthority = payment.authority; } await this.repo.save(group); @@ -1028,23 +1100,28 @@ export class BookingsService { } const paymentToken = row.paymentToken ?? randomUUID().replace(/-/g, ''); - const amountPerBooking = await Promise.all( - group.map(async (item) => ({ - id: item.id, - amount: await this.resolveBookingPriceToman(item), - })), - ); - const amountById = new Map(amountPerBooking.map((item) => [item.id, item.amount])); - for (const item of group) { - if (item.status !== BookingStatus.PENDING) continue; + for (const item of pending) { + const catalogPrice = await this.resolveBookingPriceToman(item); + const free = await this.tryReserveLoyaltyFree(item); item.status = BookingStatus.AWAITING_PAYMENT; item.paymentToken = paymentToken; - item.paymentAmount = String(amountById.get(item.id) ?? 0); + item.paymentAmount = String(free ? 0 : catalogPrice); + item.loyaltyRedeemed = free; } await this.repo.save(group); - if (!options?.skipNotify) { + const totalAmount = await this.resolveGroupAmountToman( + group.filter((item) => item.paymentToken === paymentToken), + ); + + if (totalAmount <= 0) { + const freeGroup = group.filter((item) => item.paymentToken === paymentToken); + await this.completePaymentGroup(freeGroup, `free-${Date.now()}`); + if (!options?.skipNotify) { + await this.notifyFreeBookingReady(freeGroup); + } + } else if (!options?.skipNotify) { await this.notifyPaymentReady(group); } @@ -1142,6 +1219,114 @@ export class BookingsService { ); } + private async notifyFreeBookingReady(group: BookingEntity[]): Promise { + const leader = group[0]; + if (!leader?.customer?.mobile) return; + + const businessTitle = leader.business?.title ?? 'کسب‌وکار'; + await this.sms.sendSms( + leader.customer.mobile, + `رزرو شما در «${businessTitle}» تأیید شد (رایگان — برنامه وفاداری).`, + ); + } + + private async getOrCreateLoyalty( + customerId: string, + businessServiceId: string, + ): Promise { + let row = await this.loyaltyRepo.findOne({ + where: { customerId, businessServiceId }, + }); + if (!row) { + row = this.loyaltyRepo.create({ + customerId, + businessServiceId, + paidCount: 0, + reservedBookingId: null, + }); + row = await this.loyaltyRepo.save(row); + } + return row; + } + + /** Reserve a free redeem at approval time. Returns true when this booking is free. */ + private async tryReserveLoyaltyFree(booking: BookingEntity): Promise { + const service = + booking.businessService ?? + (await this.businessServiceRepo.findOne({ + where: { id: booking.businessServiceId }, + })); + if (!service?.loyaltyEnabled || !service.loyaltyEveryN || service.loyaltyEveryN < 2) { + return false; + } + + const loyalty = await this.getOrCreateLoyalty( + booking.customerId, + booking.businessServiceId, + ); + + if (loyalty.reservedBookingId && loyalty.reservedBookingId !== booking.id) { + return false; + } + if (loyalty.paidCount < service.loyaltyEveryN) { + return false; + } + + loyalty.reservedBookingId = booking.id; + await this.loyaltyRepo.save(loyalty); + return true; + } + + private async releaseLoyaltyReservation(booking: BookingEntity): Promise { + if (!booking.loyaltyRedeemed) return; + + const loyalty = await this.loyaltyRepo.findOne({ + where: { + customerId: booking.customerId, + businessServiceId: booking.businessServiceId, + }, + }); + if (!loyalty) return; + + if (loyalty.reservedBookingId === booking.id) { + loyalty.reservedBookingId = null; + await this.loyaltyRepo.save(loyalty); + } + + if (booking.loyaltyRedeemed) { + booking.loyaltyRedeemed = false; + await this.repo.save(booking); + } + } + + private async applyLoyaltyOnComplete(booking: BookingEntity): Promise { + const service = + booking.businessService ?? + (await this.businessServiceRepo.findOne({ + where: { id: booking.businessServiceId }, + })); + if (!service?.loyaltyEnabled || !service.loyaltyEveryN || service.loyaltyEveryN < 2) { + return; + } + + const loyalty = await this.getOrCreateLoyalty( + booking.customerId, + booking.businessServiceId, + ); + + if (booking.loyaltyRedeemed) { + loyalty.paidCount = 0; + if (loyalty.reservedBookingId === booking.id) { + loyalty.reservedBookingId = null; + } + await this.loyaltyRepo.save(loyalty); + return; + } + + loyalty.paidCount += 1; + await this.loyaltyRepo.save(loyalty); + } + async getPendingReviews(actor: AuthUser): Promise< Array<{ bookingId: string; diff --git a/src/modules/business-services/business-services.service.ts b/src/modules/business-services/business-services.service.ts index 0689f8d..586de0c 100644 --- a/src/modules/business-services/business-services.service.ts +++ b/src/modules/business-services/business-services.service.ts @@ -202,6 +202,20 @@ export class BusinessServicesService { if (dto.serviceTemplateId !== undefined) { row.serviceTemplateId = dto.serviceTemplateId; } + if (dto.loyaltyEnabled !== undefined) { + row.loyaltyEnabled = dto.loyaltyEnabled; + if (!dto.loyaltyEnabled && dto.loyaltyEveryN === undefined) { + row.loyaltyEveryN = null; + } + } + if (dto.loyaltyEveryN !== undefined) { + row.loyaltyEveryN = dto.loyaltyEveryN; + } + if (row.loyaltyEnabled && (!row.loyaltyEveryN || row.loyaltyEveryN < 2)) { + throw new BadRequestException( + 'loyaltyEveryN must be at least 2 when loyalty is enabled', + ); + } return this.repo.save(row); } diff --git a/src/modules/business-services/dto/update-business-service.dto.ts b/src/modules/business-services/dto/update-business-service.dto.ts index 57764a6..552c8ae 100644 --- a/src/modules/business-services/dto/update-business-service.dto.ts +++ b/src/modules/business-services/dto/update-business-service.dto.ts @@ -24,4 +24,25 @@ export class UpdateBusinessServiceDto { @IsOptional() @IsBoolean() isActive?: boolean; + + @ApiPropertyOptional({ + example: true, + description: 'Enable free-service loyalty for this service', + }) + @IsOptional() + @IsBoolean() + loyaltyEnabled?: boolean; + + @ApiPropertyOptional({ + example: 3, + nullable: true, + description: 'After this many paid visits, the next visit of this service is free', + minimum: 2, + maximum: 100, + }) + @IsOptional() + @IsInt() + @Min(2) + @Max(100) + loyaltyEveryN?: number | null; }