import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; import { In, Repository } from 'typeorm'; import { generateAvailableSlotStarts, parseGregorianYmd, resolveServiceDurationMinutes, tehranYmd, type AvailabilityWindow, type BusyInterval, } from '../../common/utils/booking-schedule.util'; import { BookingEntity, BookingStatus } from '../../entities/booking.entity'; import { BusinessAvailabilitySlotEntity } from '../../entities/business-availability-slot.entity'; import { BusinessServiceClassPriceEntity } from '../../entities/business-service-class-price.entity'; import { BusinessServiceClassEntity } from '../../entities/business-service-class.entity'; import { BusinessServiceEntity } from '../../entities/business-service.entity'; import { BusinessEntity } from '../../entities/business.entity'; import { UserEntity, UserRole } from '../../entities/user.entity'; import { AuthUser } from '../auth/auth.types'; import { SmsService } from '../auth/sms.service'; import { ZarinpalService } from '../payments/zarinpal.service'; import { CreateBookingDto } from './dto/create-booking.dto'; import { CreateBookingBatchDto } from './dto/create-booking-batch.dto'; import { UpdateBookingDto } from './dto/update-booking.dto'; type FindAllFilters = { customerId?: string; businessId?: string; status?: BookingStatus; scope?: 'mine'; }; @Injectable() export class BookingsService { private readonly logger = new Logger(BookingsService.name); private readonly minLeadMinutes: number; private readonly webAppUrl: string; private readonly apiPublicUrl: string; constructor( private readonly config: ConfigService, private readonly sms: SmsService, private readonly zarinpal: ZarinpalService, @InjectRepository(BookingEntity) private readonly repo: Repository, @InjectRepository(UserEntity) private readonly userRepo: Repository, @InjectRepository(BusinessEntity) private readonly businessRepo: Repository, @InjectRepository(BusinessServiceEntity) private readonly businessServiceRepo: Repository, @InjectRepository(BusinessAvailabilitySlotEntity) private readonly availabilityRepo: Repository, @InjectRepository(BusinessServiceClassEntity) private readonly serviceClassRepo: Repository, @InjectRepository(BusinessServiceClassPriceEntity) private readonly classPriceRepo: Repository, ) { const raw = this.config.get('BOOKING_MIN_LEAD_MINUTES'); const parsed = raw !== undefined ? Number.parseInt(String(raw), 10) : NaN; this.minLeadMinutes = Number.isFinite(parsed) && parsed >= 0 ? parsed : 60; this.webAppUrl = (this.config.get('WEB_APP_URL') ?? 'https://montime.ir').replace( /\/$/, '', ); this.apiPublicUrl = ( this.config.get('API_PUBLIC_URL') ?? 'https://api.montime.ir' ).replace(/\/$/, ''); } async findAll( filters: FindAllFilters, actor?: AuthUser, ): Promise { const qb = this.repo .createQueryBuilder('b') .leftJoinAndSelect('b.customer', 'customer') .leftJoinAndSelect('b.business', 'business') .leftJoinAndSelect('b.businessService', 'businessService') .leftJoinAndSelect('businessService.serviceTemplate', 'template') .leftJoinAndSelect('b.serviceClass', 'serviceClass') .orderBy('b.scheduledAt', 'ASC'); if (actor?.role === UserRole.CUSTOMER) { qb.andWhere('b.customerId = :actorCustomerId', { actorCustomerId: actor.id, }); } else if (actor?.role === UserRole.BUSINESS_OWNER) { qb.andWhere( '(business.ownerId = :actorOwnerId OR b.customerId = :actorCustomerId)', { actorOwnerId: actor.id, actorCustomerId: actor.id }, ); } else if (actor?.role === UserRole.ADMIN) { if (filters.scope === 'mine') { qb.andWhere('b.customerId = :actorCustomerId', { actorCustomerId: actor.id, }); } else if (filters.customerId) { qb.andWhere('b.customerId = :customerId', { customerId: filters.customerId, }); } } else if (filters.customerId) { qb.andWhere('b.customerId = :customerId', { customerId: filters.customerId, }); } if (filters.businessId) { qb.andWhere('b.businessId = :businessId', { businessId: filters.businessId, }); } if (filters.status) { qb.andWhere('b.status = :status', { status: filters.status }); } return qb.getMany(); } async findOne(id: string, actor?: AuthUser): Promise { const row = await this.repo.findOne({ where: { id }, relations: { customer: true, business: true, businessService: { serviceTemplate: true }, serviceClass: true, }, }); if (!row) { throw new NotFoundException('Booking not found'); } this.assertCanViewBooking(row, actor); return row; } async create(dto: CreateBookingDto, actor?: AuthUser): Promise { if (actor && dto.customerId !== actor.id) { throw new ForbiddenException('You can only book for yourself'); } const customer = await this.userRepo.findOne({ where: { id: dto.customerId }, }); if (!customer) { throw new NotFoundException('User not found'); } const business = await this.businessRepo.findOne({ where: { id: dto.businessId }, }); if (!business) { throw new NotFoundException('Business not found'); } const businessService = await this.businessServiceRepo.findOne({ where: { id: dto.businessServiceId }, relations: { business: true, serviceTemplate: true }, }); if (!businessService) { throw new NotFoundException('Business service not found'); } if (businessService.businessId !== dto.businessId) { throw new BadRequestException( 'businessService does not belong to the given business', ); } const scheduledAt = this.parseScheduledAt(dto.scheduledAt); const pricing = await this.resolveBookingPricing( businessService, dto.serviceClassId ?? null, ); await this.assertSchedulableSlot({ businessId: dto.businessId, scheduledAt, durationMinutes: pricing.durationMinutes, }); const entity = this.repo.create({ customerId: dto.customerId, businessId: dto.businessId, businessServiceId: dto.businessServiceId, serviceClassId: pricing.serviceClassId, scheduledAt, note: dto.note ?? null, status: BookingStatus.PENDING, }); const saved = await this.repo.save(entity); if (business.autoApproveBookings) { return this.approveForPayment(saved.id, actor, { skipNotify: false }); } return this.findOne(saved.id, actor); } async createBatch( dto: CreateBookingBatchDto, actor?: AuthUser, ): Promise { if (actor && dto.customerId !== actor.id) { throw new ForbiddenException('You can only book for yourself'); } const customer = await this.userRepo.findOne({ where: { id: dto.customerId }, }); if (!customer) { throw new NotFoundException('User not found'); } const business = await this.businessRepo.findOne({ where: { id: dto.businessId }, }); if (!business) { throw new NotFoundException('Business not found'); } const serviceIds = [ ...new Set(dto.items.map((item) => item.businessServiceId)), ]; if (serviceIds.length !== dto.items.length) { throw new BadRequestException('Duplicate services in booking batch'); } const scheduledAt = this.parseScheduledAt(dto.scheduledAt); const serviceClassId = dto.serviceClassId ?? null; const resolvedItems: Array<{ businessService: BusinessServiceEntity; serviceClassId: string | null; durationMinutes: number; }> = []; let totalDurationMinutes = 0; for (const serviceId of serviceIds) { const businessService = await this.businessServiceRepo.findOne({ where: { id: serviceId, businessId: dto.businessId, isActive: true }, relations: { serviceTemplate: true }, }); if (!businessService) { throw new NotFoundException( `Business service ${serviceId} not found or inactive`, ); } const pricing = await this.resolveBookingPricing( businessService, serviceClassId, ); resolvedItems.push({ businessService, serviceClassId: pricing.serviceClassId, durationMinutes: pricing.durationMinutes, }); totalDurationMinutes += pricing.durationMinutes; } await this.assertSchedulableSlot({ businessId: dto.businessId, scheduledAt, durationMinutes: totalDurationMinutes, }); const bookingGroupId = serviceIds.length > 1 ? randomUUID() : null; const note = dto.note ?? null; const entities = resolvedItems.map(({ businessService, serviceClassId: classId }) => this.repo.create({ customerId: dto.customerId, businessId: dto.businessId, businessServiceId: businessService.id, serviceClassId: classId, bookingGroupId, scheduledAt, note, status: BookingStatus.PENDING, }), ); const saved = await this.repo.save(entities); if (business.autoApproveBookings && saved.length) { await this.approveForPaymentGroup(saved[0].id, actor, { skipNotify: false }); return Promise.all(saved.map((row) => this.findOne(row.id, actor))); } return Promise.all(saved.map((row) => this.findOne(row.id, actor))); } async update( id: string, dto: UpdateBookingDto, actor?: AuthUser, ): Promise { const row = await this.findOne(id); this.assertCanManageBooking(row, actor); this.assertBookingIsMutable(row); if (dto.scheduledAt !== undefined) { const scheduledAt = this.parseScheduledAt(dto.scheduledAt); const durationMinutes = await this.resolveGroupDuration(row); await this.assertSchedulableSlot({ businessId: row.businessId, scheduledAt, durationMinutes, excludeBookingId: row.id, excludeBookingGroupId: row.bookingGroupId ?? undefined, }); row.scheduledAt = scheduledAt; if (row.bookingGroupId) { await this.repo.update( { bookingGroupId: row.bookingGroupId }, { scheduledAt }, ); } } if (dto.status !== undefined) { this.applyStatusTransition(row, dto.status, actor); } if (dto.note !== undefined) { row.note = dto.note; } return this.repo.save(row); } async confirm(id: string, actor?: AuthUser): Promise { const row = await this.findOne(id, actor); this.assertCanManageBooking(row, actor); return this.approveForPayment(id, actor, { skipNotify: false }); } async complete(id: string, actor?: AuthUser): Promise { const row = await this.findOne(id); this.assertCanManageBooking(row, actor); this.applyStatusTransition(row, BookingStatus.COMPLETED, actor); return this.repo.save(row); } async cancel( id: string, actor?: AuthUser, payload?: { cancellationReason?: string }, ): Promise { const row = await this.findOne(id); this.assertCanCancelBooking(row, actor); const reason = payload?.cancellationReason?.trim() ?? ''; if (actor?.role === UserRole.BUSINESS_OWNER && !reason) { throw new BadRequestException('Cancellation reason is required'); } if (reason) { row.cancellationReason = reason; } this.applyStatusTransition(row, BookingStatus.CANCELLED, actor); return this.repo.save(row); } async findByPaymentToken( token: string, actor?: AuthUser, ): Promise<{ bookings: BookingEntity[]; totalAmountToman: number }> { const bookings = await this.repo.find({ where: { paymentToken: token }, relations: { customer: true, business: true, businessService: { serviceTemplate: true }, serviceClass: true, }, order: { createdAt: 'ASC' }, }); if (!bookings.length) { throw new NotFoundException('Payment link not found'); } for (const booking of bookings) { this.assertCanViewBooking(booking, actor); } const totalAmountToman = bookings.reduce( (sum, booking) => sum + Number.parseInt(booking.paymentAmount ?? '0', 10), 0, ); return { bookings, totalAmountToman }; } async initiatePayment( id: string, actor: AuthUser, ): Promise<{ startUrl: string; amountToman: number; mock?: boolean }> { const row = await this.findOne(id, actor); if (row.customerId !== actor.id && actor.role !== UserRole.ADMIN) { throw new ForbiddenException('Only the customer can pay for this booking'); } if (row.status !== BookingStatus.AWAITING_PAYMENT) { throw new BadRequestException('This booking is not awaiting payment'); } const group = await this.loadPaymentGroup(row); const amountToman = await this.resolveGroupAmountToman(group); const leader = group[0]!; const description = `رزرو ${leader.business?.title ?? 'منتایم'}`; const payment = await this.zarinpal.requestPayment({ amountToman, description, callbackUrl: `${this.apiPublicUrl}/bookings/payment/callback`, mobile: leader.customer?.mobile, }); if (payment.mock) { await this.completePaymentGroup(group, payment.authority); return { startUrl: '', amountToman, mock: true }; } for (const row of group) { row.paymentAuthority = payment.authority; } await this.repo.save(group); return { startUrl: payment.startUrl, amountToman }; } async initiatePaymentByToken( token: string, actor: AuthUser, ): Promise<{ startUrl: string; amountToman: number }> { const { bookings } = await this.findByPaymentToken(token, actor); const leader = bookings[0]!; return this.initiatePayment(leader.id, actor); } async handlePaymentCallback(params: { authority: string; status: string; }): Promise<{ redirectUrl: string }> { if (params.status !== 'OK') { return { redirectUrl: `${this.webAppUrl}/app/bookings?payment=failed` }; } const leader = await this.repo.findOne({ where: { paymentAuthority: params.authority }, relations: { customer: true, business: true }, }); if (!leader) { return { redirectUrl: `${this.webAppUrl}/app/bookings?payment=failed` }; } const group = await this.loadPaymentGroup(leader); const amountToman = await this.resolveGroupAmountToman(group); const token = leader.paymentToken; try { await this.zarinpal.verifyPayment({ authority: params.authority, amountToman, }); } catch (error) { this.logger.warn(`Payment verify failed: ${String(error)}`); const failUrl = token ? `${this.webAppUrl}/app/bookings/pay?token=${encodeURIComponent(token)}&payment=failed` : `${this.webAppUrl}/app/bookings?payment=failed`; return { redirectUrl: failUrl }; } await this.completePaymentGroup(group, params.authority); const successUrl = token ? `${this.webAppUrl}/app/bookings/pay?token=${encodeURIComponent(token)}&payment=success` : `${this.webAppUrl}/app/bookings?payment=success`; return { redirectUrl: successUrl }; } async remove(id: string): Promise { const result = await this.repo.delete(id); if (!result.affected) { throw new NotFoundException('Booking not found'); } } async getAvailableSlots( businessId: string, dateYmd: string, options: { businessServiceId?: string; businessServiceIds?: string[]; serviceClassId?: string; }, ): Promise<{ date: string; slots: string[]; totalDurationMinutes: number }> { parseGregorianYmd(dateYmd); const business = await this.businessRepo.findOne({ where: { id: businessId, isActive: true }, }); if (!business) { throw new NotFoundException('Business not found or inactive'); } const serviceIds = options.businessServiceIds?.length ? options.businessServiceIds : options.businessServiceId ? [options.businessServiceId] : []; if (!serviceIds.length) { throw new BadRequestException('At least one business service is required'); } const serviceClassId = options.serviceClassId ?? null; let totalDurationMinutes = 0; for (const serviceId of serviceIds) { const businessService = await this.businessServiceRepo.findOne({ where: { id: serviceId, businessId, isActive: true }, relations: { serviceTemplate: true }, }); if (!businessService) { throw new NotFoundException('Business service not found or inactive'); } const pricing = await this.resolveBookingPricing( businessService, serviceClassId, ); totalDurationMinutes += pricing.durationMinutes; } const windows = await this.loadAvailabilityWindows(businessId); const busy = await this.loadBusyIntervals(businessId, dateYmd); const { concurrentCapacity, slotStepMinutes } = await this.getBusinessBookingSettings(businessId); const slots = generateAvailableSlotStarts({ dateYmd, durationMinutes: totalDurationMinutes, leadMinutes: this.minLeadMinutes, slotStepMinutes, windows, busy, concurrentCapacity, }); return { date: dateYmd, slots, totalDurationMinutes }; } private async getBusinessBookingSettings(businessId: string): Promise<{ concurrentCapacity: number; slotStepMinutes: number; }> { const business = await this.businessRepo.findOne({ where: { id: businessId }, select: ['id', 'concurrentCapacity', 'slotStepMinutes'], }); return { concurrentCapacity: Math.max(1, business?.concurrentCapacity ?? 1), slotStepMinutes: Math.max(5, business?.slotStepMinutes ?? 30), }; } private async loadAvailabilityWindows( businessId: string, ): Promise { const rows = await this.availabilityRepo.find({ where: { businessId } }); return rows.map((r) => ({ dayOfWeek: r.dayOfWeek, startMinute: r.startMinute, endMinute: r.endMinute, })); } private async loadBusyIntervals( businessId: string, dateYmd: string, options?: { excludeBookingId?: string; excludeBookingGroupId?: string }, ): Promise { const dayStart = new Date(`${dateYmd}T00:00:00+03:30`); const dayEnd = new Date(`${dateYmd}T23:59:59+03:30`); let excludeGroupId = options?.excludeBookingGroupId ?? null; if (options?.excludeBookingId && !excludeGroupId) { const excluded = await this.repo.findOne({ where: { id: options.excludeBookingId }, select: ['id', 'bookingGroupId'], }); excludeGroupId = excluded?.bookingGroupId ?? null; } const qb = this.repo .createQueryBuilder('b') .leftJoinAndSelect('b.businessService', 'bs') .leftJoinAndSelect('bs.serviceTemplate', 'tpl') .where('b.businessId = :businessId', { businessId }) .andWhere('b.status IN (:...statuses)', { statuses: [ BookingStatus.PENDING, BookingStatus.AWAITING_PAYMENT, BookingStatus.CONFIRMED, ], }) .andWhere('b.scheduledAt >= :dayStart', { dayStart }) .andWhere('b.scheduledAt <= :dayEnd', { dayEnd }); if (options?.excludeBookingId && !excludeGroupId) { qb.andWhere('b.id != :excludeBookingId', { excludeBookingId: options.excludeBookingId, }); } if (excludeGroupId) { qb.andWhere('b.bookingGroupId IS NULL OR b.bookingGroupId != :excludeGroupId', { excludeGroupId, }); } const bookings = await qb.getMany(); const durations = await Promise.all( bookings.map((b) => this.resolveBookingDuration(b)), ); const groupTotals = new Map< string, { startMs: number; totalDuration: number } >(); const singles: BusyInterval[] = []; for (const [index, booking] of bookings.entries()) { const duration = durations[index] ?? 30; const startMs = booking.scheduledAt.getTime(); if (booking.bookingGroupId) { const key = `${booking.bookingGroupId}:${startMs}`; const existing = groupTotals.get(key); if (existing) { existing.totalDuration += duration; } else { groupTotals.set(key, { startMs, totalDuration: duration }); } } else { singles.push({ startMs, endMs: startMs + duration * 60 * 1000, }); } } return [ ...singles, ...Array.from(groupTotals.values()).map((group) => ({ startMs: group.startMs, endMs: group.startMs + group.totalDuration * 60 * 1000, })), ]; } private async resolveBookingPricing( businessService: BusinessServiceEntity, serviceClassId: string | null, ): Promise<{ serviceClassId: string | null; durationMinutes: number }> { const business = await this.businessRepo.findOne({ where: { id: businessService.businessId }, }); const activeClassCount = await this.serviceClassRepo.count({ where: { businessId: businessService.businessId, isActive: true }, }); const usesClasses = business?.serviceClassesEnabled === true && activeClassCount > 0; if (!usesClasses) { if (serviceClassId) { throw new BadRequestException( 'This business does not use service classes', ); } return { serviceClassId: null, durationMinutes: resolveServiceDurationMinutes( businessService.durationMinutes, businessService.serviceTemplate?.defaultDurationMinutes, ), }; } if (!serviceClassId) { throw new BadRequestException('serviceClassId is required for this business'); } const serviceClass = await this.serviceClassRepo.findOne({ where: { id: serviceClassId, businessId: businessService.businessId, isActive: true, }, }); if (!serviceClass) { throw new BadRequestException('Service class not found or inactive'); } const classPrice = await this.classPriceRepo.findOne({ where: { businessServiceId: businessService.id, classId: serviceClassId, isActive: true, }, }); if (!classPrice) { throw new BadRequestException( 'This service is not available for the selected class', ); } return { serviceClassId, durationMinutes: resolveServiceDurationMinutes( classPrice.durationMinutes ?? businessService.durationMinutes, businessService.serviceTemplate?.defaultDurationMinutes, ), }; } private async resolveGroupDuration(booking: BookingEntity): Promise { if (!booking.bookingGroupId) { return this.resolveBookingDuration(booking); } const siblings = await this.repo.find({ where: { bookingGroupId: booking.bookingGroupId, status: In([ BookingStatus.PENDING, BookingStatus.AWAITING_PAYMENT, BookingStatus.CONFIRMED, ]), }, relations: { businessService: { serviceTemplate: true } }, }); const rows = siblings.length ? siblings : [ { ...booking, businessService: booking.businessService ?? (await this.businessServiceRepo.findOne({ where: { id: booking.businessServiceId }, relations: { serviceTemplate: true }, })), } as BookingEntity, ]; let total = 0; for (const row of rows) { total += await this.resolveBookingDuration(row); } return total; } private async resolveBookingDuration(booking: BookingEntity): Promise { if (!booking.businessService) { const businessService = await this.businessServiceRepo.findOne({ where: { id: booking.businessServiceId }, relations: { serviceTemplate: true }, }); if (!businessService) { return 30; } booking.businessService = businessService; } if (booking.serviceClassId) { const classPrice = await this.classPriceRepo.findOne({ where: { businessServiceId: booking.businessServiceId, classId: booking.serviceClassId, }, }); return resolveServiceDurationMinutes( classPrice?.durationMinutes ?? booking.businessService.durationMinutes, booking.businessService.serviceTemplate?.defaultDurationMinutes, ); } return resolveServiceDurationMinutes( booking.businessService.durationMinutes, booking.businessService.serviceTemplate?.defaultDurationMinutes, ); } private async assertSchedulableSlot(params: { businessId: string; scheduledAt: Date; durationMinutes: number; excludeBookingId?: string; excludeBookingGroupId?: string; }): Promise { const { businessId, scheduledAt, durationMinutes, excludeBookingId, excludeBookingGroupId, } = params; this.assertLeadTime(scheduledAt); const dateYmd = tehranYmd(scheduledAt); const windows = await this.loadAvailabilityWindows(businessId); if (!windows.length) { throw new BadRequestException( 'Online booking hours are not configured for this business', ); } const { concurrentCapacity, slotStepMinutes } = await this.getBusinessBookingSettings(businessId); const slots = generateAvailableSlotStarts({ dateYmd, durationMinutes, leadMinutes: this.minLeadMinutes, slotStepMinutes, windows, busy: await this.loadBusyIntervals(businessId, dateYmd, { excludeBookingId, excludeBookingGroupId, }), concurrentCapacity, }); const scheduledMs = scheduledAt.getTime(); const allowed = slots.some((s) => new Date(s).getTime() === scheduledMs); if (!allowed) { throw new BadRequestException( 'Selected time is not available for this service', ); } } private parseScheduledAt(raw: string): Date { const d = new Date(raw); if (Number.isNaN(d.getTime())) { throw new BadRequestException('scheduledAt must be a valid ISO datetime'); } return d; } private assertLeadTime(scheduledAt: Date): void { const minMs = this.minLeadMinutes * 60 * 1000; const earliest = Date.now() + minMs; if (scheduledAt.getTime() < earliest) { throw new BadRequestException( `scheduledAt must be at least ${this.minLeadMinutes} minute(s) after the current time`, ); } } private assertBookingIsMutable(row: BookingEntity): void { if ( row.status === BookingStatus.CANCELLED || row.status === BookingStatus.COMPLETED ) { throw new BadRequestException( 'Cancelled or completed bookings cannot be edited', ); } } private applyStatusTransition( row: BookingEntity, nextStatus: BookingStatus, actor?: AuthUser, ): void { if (row.status === nextStatus) { return; } // همه قوانین تغییر وضعیت رزرو در همین متد نگه داشته می‌شود تا مسیرهای مختلف // (PATCH، confirm، complete، cancel) رفتار یکسان داشته باشند. if ( row.status === BookingStatus.CANCELLED || row.status === BookingStatus.COMPLETED ) { throw new BadRequestException( 'Cancelled or completed bookings cannot change status', ); } if (nextStatus === BookingStatus.CANCELLED) { // مشتری فقط رزرو خودش را لغو می‌کند؛ صاحب کسب‌وکار فقط رزرو کسب‌وکار خودش را. this.assertCanCancelBooking(row, actor); row.status = BookingStatus.CANCELLED; return; } if (actor?.role === UserRole.CUSTOMER) { throw new ForbiddenException('Customers can only cancel bookings'); } if (nextStatus === BookingStatus.CONFIRMED) { if (row.status !== BookingStatus.AWAITING_PAYMENT) { throw new BadRequestException( 'Only bookings awaiting payment can be confirmed', ); } row.status = BookingStatus.CONFIRMED; return; } if (nextStatus === BookingStatus.AWAITING_PAYMENT) { if (row.status !== BookingStatus.PENDING) { throw new BadRequestException('Only pending bookings can be approved'); } row.status = BookingStatus.AWAITING_PAYMENT; return; } if (nextStatus === BookingStatus.COMPLETED) { if (row.status !== BookingStatus.CONFIRMED) { throw new BadRequestException('Only confirmed bookings can be completed'); } row.status = BookingStatus.COMPLETED; return; } throw new BadRequestException('Unsupported booking status transition'); } private assertCanViewBooking(row: BookingEntity, actor?: AuthUser): void { if (!actor || actor.role === UserRole.ADMIN) { return; } if (actor.role === UserRole.CUSTOMER && row.customerId === actor.id) { return; } if ( actor.role === UserRole.BUSINESS_OWNER && (row.business?.ownerId === actor.id || row.customerId === actor.id) ) { return; } throw new ForbiddenException('You cannot access this booking'); } private assertCanCancelBooking(row: BookingEntity, actor?: AuthUser): void { if (!actor || actor.role === UserRole.ADMIN) { return; } if (actor.role === UserRole.CUSTOMER && row.customerId === actor.id) { return; } if ( actor.role === UserRole.BUSINESS_OWNER && (row.business?.ownerId === actor.id || row.customerId === actor.id) ) { return; } throw new ForbiddenException('You cannot cancel this booking'); } private assertCanManageBooking(row: BookingEntity, actor?: AuthUser): void { if (!actor || actor.role === UserRole.ADMIN) { return; } if ( actor.role === UserRole.BUSINESS_OWNER && (row.business?.ownerId === actor.id || row.customerId === actor.id) ) { return; } throw new ForbiddenException('You can only manage your own business bookings'); } private async approveForPayment( id: string, actor?: AuthUser, options?: { skipNotify?: boolean }, ): Promise { return this.approveForPaymentGroup(id, actor, options); } private async approveForPaymentGroup( id: string, actor?: AuthUser, options?: { skipNotify?: boolean }, ): Promise { const row = await this.findOne(id, actor); if (actor?.role === UserRole.BUSINESS_OWNER || actor?.role === UserRole.ADMIN) { this.assertCanManageBooking(row, actor); } const group = await this.loadPaymentGroup(row); const pending = group.filter((item) => item.status === BookingStatus.PENDING); if (!pending.length) { throw new BadRequestException('Only pending bookings can be approved'); } 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; item.status = BookingStatus.AWAITING_PAYMENT; item.paymentToken = paymentToken; item.paymentAmount = String(amountById.get(item.id) ?? 0); } await this.repo.save(group); if (!options?.skipNotify) { await this.notifyPaymentReady(group); } return this.findOne(id, actor); } private async loadPaymentGroup(row: BookingEntity): Promise { if (!row.bookingGroupId) { const fresh = await this.findOne(row.id); return [fresh]; } const siblings = await this.repo.find({ where: { bookingGroupId: row.bookingGroupId }, relations: { customer: true, business: true, businessService: { serviceTemplate: true }, serviceClass: true, }, order: { createdAt: 'ASC' }, }); return siblings.length ? siblings : [row]; } private async resolveGroupAmountToman(group: BookingEntity[]): Promise { let total = 0; for (const row of group) { if (row.paymentAmount) { total += Number.parseInt(row.paymentAmount, 10); } else { total += await this.resolveBookingPriceToman(row); } } return total; } private async resolveBookingPriceToman(booking: BookingEntity): Promise { let businessService = booking.businessService; if (!businessService) { const loaded = await this.businessServiceRepo.findOne({ where: { id: booking.businessServiceId }, relations: { serviceTemplate: true }, }); if (!loaded) { return 0; } businessService = loaded; booking.businessService = loaded; } if (booking.serviceClassId) { const classPrice = await this.classPriceRepo.findOne({ where: { businessServiceId: booking.businessServiceId, classId: booking.serviceClassId, isActive: true, }, }); if (classPrice) { return Math.round(Number.parseFloat(classPrice.price)); } } return Math.round(Number.parseFloat(businessService.price)); } private buildPaymentPageUrl(token: string): string { return `${this.webAppUrl}/app/bookings/pay?token=${encodeURIComponent(token)}`; } private async completePaymentGroup( group: BookingEntity[], authority: string, ): Promise { for (const row of group) { row.status = BookingStatus.CONFIRMED; row.paymentAuthority = authority; } await this.repo.save(group); } private async notifyPaymentReady(group: BookingEntity[]): Promise { const leader = group[0]; if (!leader?.paymentToken || !leader.customer?.mobile) { return; } const businessTitle = leader.business?.title ?? 'کسب‌وکار'; const payUrl = this.buildPaymentPageUrl(leader.paymentToken); const amount = await this.resolveGroupAmountToman(group); await this.sms.sendSms( leader.customer.mobile, `رزرو شما در «${businessTitle}» تأیید شد. مبلغ ${amount.toLocaleString('fa-IR')} تومان — پرداخت: ${payUrl}`, ); } }