import { BadRequestException, ForbiddenException, forwardRef, Inject, Injectable, Logger, NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectRepository } from '@nestjs/typeorm'; import { randomUUID } from 'crypto'; import { Repository } from 'typeorm'; import { AuthUser } from '../auth/interfaces/auth-user.interface'; import { AuthorizationService } from '../authorization/authorization.service'; import { ReservePolicy } from '../authorization/policies/reserve.policy'; import { SalonNotificationsService } from '../notifications/salon-notifications.service'; import { ReserveStatus } from '../reserves/enums/reserve-status.enum'; import { ReservesService } from '../reserves/reserves.service'; import { calculateSmsParts } from '../sms/helpers/sms-segment.helper'; import { SmsService } from '../sms/sms.service'; import { SmsDispatchService } from '../sms-settings/sms-dispatch.service'; import { SmsScenarioKey } from '../sms-settings/enums/sms-scenario-key.enum'; import { buildReserveSmsVariables } from '../sms-settings/utils/build-reserve-sms-variables.util'; import { SalonFinancialSettingsService } from '../salons/salon-financial-settings.service'; import { SalonSubscriptionsService } from '../subscriptions/salon-subscriptions.service'; import { UploaderService } from '../uploader/uploader.service'; import { UserRole } from '../users/enums/user-role.enum'; import { ShortLinksService } from '../short-links/short-links.service'; import { DepositListItemDto } from './dto/deposit-list-item.dto'; import { FindDepositsQueryDto } from './dto/find-deposits-query.dto'; import { PublicDepositPaymentDto } from './dto/public-deposit-payment.dto'; import { ReviewDepositDto } from './dto/review-deposit.dto'; import { Payment } from './entities/payment.entity'; import { PaymentMethod } from './enums/payment-method.enum'; import { PaymentStatus } from './enums/payment-status.enum'; import { PaymentType } from './enums/payment-type.enum'; const MIN_AMOUNT_TOMAN = 100; /** Gives the reserve-created SMS time to reach the customer before the deposit link SMS. */ const DEPOSIT_SMS_DELAY_MS = 2_500; @Injectable() export class CardToCardDepositService { private readonly logger = new Logger(CardToCardDepositService.name); private readonly frontendUrl: string; constructor( @InjectRepository(Payment) private readonly paymentsRepository: Repository, private readonly reservesService: ReservesService, private readonly reservePolicy: ReservePolicy, private readonly authorizationService: AuthorizationService, private readonly salonFinancialSettingsService: SalonFinancialSettingsService, private readonly uploaderService: UploaderService, private readonly smsDispatchService: SmsDispatchService, private readonly smsService: SmsService, private readonly shortLinksService: ShortLinksService, private readonly salonNotificationsService: SalonNotificationsService, @Inject(forwardRef(() => SalonSubscriptionsService)) private readonly salonSubscriptionsService: SalonSubscriptionsService, private readonly configService: ConfigService, ) { this.frontendUrl = ( this.configService.get('FRONTEND_URL') ?? 'http://localhost:3000' ).replace(/\/$/, ''); } async requestDeposit(reserveId: string, user: AuthUser): Promise { if (user.role !== UserRole.SALON && user.role !== UserRole.ADMIN) { throw new ForbiddenException(); } const reserve = await this.reservesService.findOne(reserveId, user); if (!this.reservePolicy.canRead(user, reserve)) { throw new ForbiddenException(); } if (reserve.status !== ReserveStatus.PENDING) { throw new BadRequestException( 'Only pending reserves can be paid for deposit', ); } if (reserve.depositAmount <= 0) { throw new BadRequestException('This reserve has no deposit amount'); } if (reserve.depositAmount < MIN_AMOUNT_TOMAN) { throw new BadRequestException( `Minimum deposit amount is ${MIN_AMOUNT_TOMAN} Toman`, ); } const existingPaid = await this.paymentsRepository.existsBy({ reserveId, type: PaymentType.DEPOSIT, status: PaymentStatus.PAID, }); if (existingPaid) { throw new BadRequestException('Deposit has already been paid'); } // Ensures the salon has configured a card before a request is sent to the customer. await this.salonFinancialSettingsService.findBySalonId(reserve.salonId); const existingPending = await this.paymentsRepository.findOne({ where: { reserveId, type: PaymentType.DEPOSIT, method: PaymentMethod.CARD_TO_CARD, }, order: { createdAt: 'DESC' }, }); if (existingPending?.status === PaymentStatus.IN_REVIEW) { throw new BadRequestException( 'رسید پرداخت قبلی در انتظار بررسی است', ); } const payment = existingPending && [PaymentStatus.PENDING, PaymentStatus.REJECTED].includes( existingPending.status, ) ? existingPending : this.paymentsRepository.create({ type: PaymentType.DEPOSIT, method: PaymentMethod.CARD_TO_CARD, amountRial: this.tomanToRial(reserve.depositAmount), status: PaymentStatus.PENDING, description: `پرداخت کارت به کارت بیعانه رزرو ${reserve.id}`, reserveId: reserve.id, publicToken: randomUUID(), }); if (payment.status === PaymentStatus.REJECTED) { payment.status = PaymentStatus.PENDING; payment.rejectionReason = null; payment.receiptUrl = null; } const saved = await this.paymentsRepository.save(payment); const paymentLink = await this.shortLinksService.createShortUrl( `${this.frontendUrl}/pay/${saved.publicToken}`, ); const variables = buildReserveSmsVariables(reserve); const mobile = reserve.customer?.user?.mobile; if (mobile) { await this.delay(DEPOSIT_SMS_DELAY_MS); await this.sendDepositSms( SmsScenarioKey.DEPOSIT_PAYMENT_REQUEST, mobile, { ...variables, paymentLink }, reserve.salonId, ); } return saved; } async getPublicPaymentByToken(token: string): Promise { const payment = await this.paymentsRepository.findOne({ where: { publicToken: token }, relations: { reserve: { salon: true } }, }); if (!payment || !payment.reserve) { throw new NotFoundException('لینک پرداخت یافت نشد'); } const financial = await this.salonFinancialSettingsService.findBySalonId( payment.reserve.salonId, ); return { paymentId: payment.id, status: payment.status, amountToman: this.rialToToman(payment.amountRial), salon: { name: payment.reserve.salon.name, address: payment.reserve.salon.address, images: payment.reserve.salon.images ?? [], }, card: { cardNumber: financial.cardNumber, cardHolderName: financial.cardHolderName, }, appointmentDate: payment.reserve.appointmentDate ?? null, startTime: payment.reserve.startTime ?? null, rejectionReason: payment.rejectionReason, }; } async submitReceipt( token: string, file: Express.Multer.File, ): Promise { const payment = await this.paymentsRepository.findOne({ where: { publicToken: token }, relations: { reserve: { salon: { owner: true }, customer: { user: true } }, }, }); if (!payment || !payment.reserve) { throw new NotFoundException('لینک پرداخت یافت نشد'); } if ( payment.status !== PaymentStatus.PENDING && payment.status !== PaymentStatus.REJECTED ) { throw new BadRequestException( 'این پرداخت قبلاً ثبت شده و در انتظار بررسی یا تأیید است', ); } const uploaded = await this.uploaderService.uploadSingle(file); payment.receiptUrl = uploaded.url; payment.status = PaymentStatus.IN_REVIEW; payment.rejectionReason = null; const saved = await this.paymentsRepository.save(payment); const reserve = payment.reserve; const ownerMobile = reserve.salon?.owner?.mobile; const variables = buildReserveSmsVariables(reserve); if (ownerMobile) { const reviewLink = await this.shortLinksService.createShortUrl( `${this.frontendUrl}/deposits/${saved.id}/review`, ); await this.sendDepositSms( SmsScenarioKey.DEPOSIT_RECEIPT_SUBMITTED, ownerMobile, { ...variables, reviewLink }, reserve.salonId, ); } await this.salonNotificationsService.notifyDepositReceiptSubmitted({ salonId: reserve.salonId, salonOwnerId: reserve.salon.ownerId, salonName: reserve.salon.name, customerName: variables.customerName, depositAmount: reserve.depositAmount, paymentId: saved.id, }); return this.getPublicPaymentByToken(token); } async findForSalon( user: AuthUser, query: FindDepositsQueryDto, ): Promise { if (user.role !== UserRole.SALON && user.role !== UserRole.ADMIN) { throw new ForbiddenException(); } const qb = this.paymentsRepository .createQueryBuilder('payment') .innerJoinAndSelect('payment.reserve', 'reserve') .innerJoinAndSelect('reserve.salon', 'salon') .leftJoinAndSelect('reserve.customer', 'customer') .leftJoinAndSelect('customer.user', 'customerUser') .where('payment.type = :type', { type: PaymentType.DEPOSIT }) .andWhere('payment.method = :method', { method: PaymentMethod.CARD_TO_CARD, }) .orderBy('payment.createdAt', 'DESC'); if (query.status) { qb.andWhere('payment.status = :status', { status: query.status }); } if (!this.authorizationService.isAdmin(user)) { qb.andWhere('salon.ownerId = :ownerId', { ownerId: user.id }); } const limit = query.limit ?? 50; const offset = query.offset ?? 0; qb.take(limit).skip(offset); const payments = await qb.getMany(); return payments.map((payment) => this.toDepositListItem(payment)); } async findForReview(paymentId: string, user: AuthUser): Promise { const payment = await this.loadPaymentWithRelations(paymentId); this.assertCanReview(user, payment); return payment; } async reviewDeposit( paymentId: string, dto: ReviewDepositDto, user: AuthUser, ): Promise { const payment = await this.loadPaymentWithRelations(paymentId); this.assertCanReview(user, payment); if (payment.status !== PaymentStatus.IN_REVIEW) { throw new BadRequestException( 'این پرداخت در وضعیت قابل بررسی نیست', ); } // Guaranteed non-null by loadPaymentWithRelations(). const reserve = payment.reserve!; const variables = buildReserveSmsVariables(reserve); const customerMobile = reserve.customer?.user?.mobile; if (dto.decision === 'approve') { payment.status = PaymentStatus.PAID; payment.reviewedAt = new Date(); payment.reviewedByUserId = user.id; const saved = await this.paymentsRepository.save(payment); await this.reservesService.confirmDepositPayment(reserve.id, user.id); if (customerMobile) { await this.sendDepositSms( SmsScenarioKey.DEPOSIT_APPROVED, customerMobile, variables, reserve.salonId, ); } return saved; } payment.status = PaymentStatus.REJECTED; payment.rejectionReason = dto.rejectionReason ?? null; payment.reviewedAt = new Date(); payment.reviewedByUserId = user.id; const saved = await this.paymentsRepository.save(payment); if (customerMobile) { const paymentLink = await this.shortLinksService.createShortUrl( `${this.frontendUrl}/pay/${saved.publicToken}`, ); await this.sendDepositSms( SmsScenarioKey.DEPOSIT_REJECTED, customerMobile, { ...variables, rejectionReason: dto.rejectionReason ?? '—', paymentLink, }, reserve.salonId, ); } return saved; } private async loadPaymentWithRelations(paymentId: string): Promise { const payment = await this.paymentsRepository.findOne({ where: { id: paymentId }, relations: { reserve: { salon: true, customer: { user: true } }, }, }); if (!payment || !payment.reserve) { throw new NotFoundException(`Payment #${paymentId} not found`); } return payment; } private assertCanReview(user: AuthUser, payment: Payment): void { if (this.authorizationService.isAdmin(user)) { return; } if ( user.role === UserRole.SALON && payment.reserve?.salon.ownerId === user.id ) { return; } throw new ForbiddenException(); } /** Resolves the scenario message, deducts the salon's SMS balance, then sends it. */ private async sendDepositSms( key: SmsScenarioKey, mobile: string, variables: Record, salonId: string, ): Promise { const message = await this.smsDispatchService.resolveMessage(key, variables); if (!message) { return false; } try { await this.salonSubscriptionsService.consumeSms( salonId, calculateSmsParts(message), ); } catch (error) { this.logger.warn( `Skipping deposit SMS "${key}" for salon ${salonId}: ${(error as Error).message}`, ); return false; } return this.smsService.sendSms(mobile, message); } private delay(ms: number): Promise { return new Promise((resolve) => setTimeout(resolve, ms)); } private tomanToRial(amountToman: number): number { return Math.round(amountToman * 10); } private rialToToman(amountRial: number): number { return Math.round(amountRial / 10); } private toDepositListItem(payment: Payment): DepositListItemDto { const user = payment.reserve?.customer?.user; const customerName = user ? `${user.firstName} ${user.lastName}`.trim() : 'مشتری'; return { id: payment.id, status: payment.status, amountToman: this.rialToToman(payment.amountRial), receiptUrl: payment.receiptUrl, createdAt: payment.createdAt, customerName, customerMobile: user?.mobile ?? null, appointmentDate: payment.reserve?.appointmentDate ?? null, startTime: payment.reserve?.startTime ?? null, }; } }