diff --git a/.env.example b/.env.example index da02e7c..70c87f1 100644 --- a/.env.example +++ b/.env.example @@ -44,3 +44,12 @@ SMS_SENDER= # Optional: log Server-Timing to PM2 stdout (total;dur=… per request) # LOG_SERVER_TIMING=true + +# Public URLs (payment links in SMS + Zarinpal callback) +WEB_APP_URL=https://montime.ir +API_PUBLIC_URL=https://api.montime.ir + +# Online payment (Zarinpal). بدون ZARINPAL_MERCHANT_ID پرداخت سوری فعال است. +# ZARINPAL_MERCHANT_ID= +# ZARINPAL_SANDBOX=true +# PAYMENT_MOCK_MODE=false # فقط وقتی مرچنت دارید و می‌خواهید درگاه واقعی برود diff --git a/src/entities/booking.entity.ts b/src/entities/booking.entity.ts index bdec315..db9c31e 100644 --- a/src/entities/booking.entity.ts +++ b/src/entities/booking.entity.ts @@ -14,6 +14,7 @@ import { UserEntity } from './user.entity'; export enum BookingStatus { PENDING = 'pending', + AWAITING_PAYMENT = 'awaiting_payment', CONFIRMED = 'confirmed', CANCELLED = 'cancelled', COMPLETED = 'completed', @@ -69,6 +70,16 @@ export class BookingEntity { @Column({ type: 'text', nullable: true }) cancellationReason!: string | null; + /** Shared token for paying a booking group (or single booking). */ + @Column({ type: 'varchar', nullable: true, length: 64 }) + paymentToken!: string | null; + + @Column({ type: 'decimal', precision: 12, scale: 0, nullable: true }) + paymentAmount!: string | null; + + @Column({ type: 'varchar', nullable: true, length: 64 }) + paymentAuthority!: string | null; + @CreateDateColumn() createdAt!: Date; diff --git a/src/entities/business.entity.ts b/src/entities/business.entity.ts index 4dbc344..28201b7 100644 --- a/src/entities/business.entity.ts +++ b/src/entities/business.entity.ts @@ -58,6 +58,10 @@ export class BusinessEntity { @Column({ type: 'int', default: 30 }) slotStepMinutes!: number; + /** When true, new bookings skip manual owner approval and go straight to payment. */ + @Column({ default: false }) + autoApproveBookings!: boolean; + @ManyToOne(() => BusinessCategoryEntity, (category) => category.businesses, { onDelete: 'RESTRICT', }) diff --git a/src/modules/bookings/bookings.controller.ts b/src/modules/bookings/bookings.controller.ts index 6902d78..f1bcc99 100644 --- a/src/modules/bookings/bookings.controller.ts +++ b/src/modules/bookings/bookings.controller.ts @@ -7,6 +7,7 @@ import { Patch, Post, Query, + Redirect, UseGuards, } from '@nestjs/common'; import { ApiQuery } from '@nestjs/swagger'; @@ -46,6 +47,39 @@ export class BookingsController { }); } + @Get('payment/callback') + @Redirect() + async paymentCallback( + @Query('Authority') authority: string, + @Query('Status') status: string, + ): Promise<{ url: string; statusCode: number }> { + const { redirectUrl } = await this.service.handlePaymentCallback({ + authority, + status, + }); + return { url: redirectUrl, statusCode: 302 }; + } + + @Get('payment/by-token/:token') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER) + findByPaymentToken( + @Param('token') token: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.findByPaymentToken(token, user); + } + + @Post('payment/by-token/:token/init') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER) + initiatePaymentByToken( + @Param('token') token: string, + @CurrentUser() user: AuthUser, + ) { + return this.service.initiatePaymentByToken(token, user); + } + @Get() @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER) @@ -114,6 +148,13 @@ export class BookingsController { return this.service.confirm(id, user); } + @Post(':id/payment/init') + @UseGuards(JwtAuthGuard, RolesGuard) + @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER) + initiatePayment(@Param('id') id: string, @CurrentUser() user: AuthUser) { + return this.service.initiatePayment(id, user); + } + @Post(':id/complete') @UseGuards(JwtAuthGuard, RolesGuard) @Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER) diff --git a/src/modules/bookings/bookings.module.ts b/src/modules/bookings/bookings.module.ts index c2db3b4..1c18d1e 100644 --- a/src/modules/bookings/bookings.module.ts +++ b/src/modules/bookings/bookings.module.ts @@ -9,6 +9,7 @@ import { BusinessEntity } from '../../entities/business.entity'; import { ServiceTemplateEntity } from '../../entities/service-template.entity'; import { UserEntity } from '../../entities/user.entity'; import { AuthModule } from '../auth/auth.module'; +import { PaymentsModule } from '../payments/payments.module'; import { BookingsController } from './bookings.controller'; import { BookingsService } from './bookings.service'; @@ -25,6 +26,7 @@ import { BookingsService } from './bookings.service'; ServiceTemplateEntity, ]), AuthModule, + PaymentsModule, ], controllers: [BookingsController], providers: [BookingsService], diff --git a/src/modules/bookings/bookings.service.ts b/src/modules/bookings/bookings.service.ts index cf95376..2a2c9d1 100644 --- a/src/modules/bookings/bookings.service.ts +++ b/src/modules/bookings/bookings.service.ts @@ -2,6 +2,7 @@ import { BadRequestException, ForbiddenException, Injectable, + Logger, NotFoundException, } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; @@ -24,6 +25,8 @@ 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'; @@ -37,10 +40,15 @@ type FindAllFilters = { @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) @@ -59,6 +67,13 @@ export class BookingsService { 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( @@ -181,7 +196,11 @@ export class BookingsService { status: BookingStatus.PENDING, }); - return this.repo.save(entity); + 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( @@ -268,7 +287,12 @@ export class BookingsService { }), ); - return this.repo.save(entities); + 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( @@ -311,10 +335,9 @@ export class BookingsService { } async confirm(id: string, actor?: AuthUser): Promise { - const row = await this.findOne(id); + const row = await this.findOne(id, actor); this.assertCanManageBooking(row, actor); - this.applyStatusTransition(row, BookingStatus.CONFIRMED, actor); - return this.repo.save(row); + return this.approveForPayment(id, actor, { skipNotify: false }); } async complete(id: string, actor?: AuthUser): Promise { @@ -344,6 +367,120 @@ export class BookingsService { 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) { @@ -465,7 +602,11 @@ export class BookingsService { .leftJoinAndSelect('bs.serviceTemplate', 'tpl') .where('b.businessId = :businessId', { businessId }) .andWhere('b.status IN (:...statuses)', { - statuses: [BookingStatus.PENDING, BookingStatus.CONFIRMED], + statuses: [ + BookingStatus.PENDING, + BookingStatus.AWAITING_PAYMENT, + BookingStatus.CONFIRMED, + ], }) .andWhere('b.scheduledAt >= :dayStart', { dayStart }) .andWhere('b.scheduledAt <= :dayEnd', { dayEnd }); @@ -595,7 +736,11 @@ export class BookingsService { const siblings = await this.repo.find({ where: { bookingGroupId: booking.bookingGroupId, - status: In([BookingStatus.PENDING, BookingStatus.CONFIRMED]), + status: In([ + BookingStatus.PENDING, + BookingStatus.AWAITING_PAYMENT, + BookingStatus.CONFIRMED, + ]), }, relations: { businessService: { serviceTemplate: true } }, }); @@ -762,13 +907,23 @@ export class BookingsService { } if (nextStatus === BookingStatus.CONFIRMED) { - if (row.status !== BookingStatus.PENDING) { - throw new BadRequestException('Only pending bookings can be 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'); @@ -832,4 +987,143 @@ export class BookingsService { 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}`, + ); + } } diff --git a/src/modules/businesses/dto/update-business.dto.ts b/src/modules/businesses/dto/update-business.dto.ts index fe032f6..4801b3c 100644 --- a/src/modules/businesses/dto/update-business.dto.ts +++ b/src/modules/businesses/dto/update-business.dto.ts @@ -109,4 +109,13 @@ export class UpdateBusinessDto { @Min(5) @Max(120) slotStepMinutes?: number; + + @ApiPropertyOptional({ + example: false, + description: + 'When true, customer bookings are approved automatically and proceed to online payment', + }) + @IsOptional() + @IsBoolean() + autoApproveBookings?: boolean; } diff --git a/src/modules/payments/payments.module.ts b/src/modules/payments/payments.module.ts new file mode 100644 index 0000000..c84fd48 --- /dev/null +++ b/src/modules/payments/payments.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { ZarinpalService } from './zarinpal.service'; + +@Module({ + providers: [ZarinpalService], + exports: [ZarinpalService], +}) +export class PaymentsModule {} diff --git a/src/modules/payments/zarinpal.service.ts b/src/modules/payments/zarinpal.service.ts new file mode 100644 index 0000000..d50ac9d --- /dev/null +++ b/src/modules/payments/zarinpal.service.ts @@ -0,0 +1,149 @@ +import { BadRequestException, Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; + +type ZarinpalRequestResponse = { + data?: { + code: number; + message: string; + authority?: string; + fee_type?: string; + fee?: number; + }; + errors?: unknown; +}; + +type ZarinpalVerifyResponse = { + data?: { + code: number; + message: string; + ref_id?: number; + card_pan?: string; + fee_type?: string; + fee?: number; + }; + errors?: unknown; +}; + +@Injectable() +export class ZarinpalService { + private readonly logger = new Logger(ZarinpalService.name); + private readonly merchantId: string; + private readonly sandbox: boolean; + private readonly devMode: boolean; + + constructor(private readonly config: ConfigService) { + this.merchantId = this.config.get('ZARINPAL_MERCHANT_ID') ?? ''; + this.sandbox = this.config.get('ZARINPAL_SANDBOX') === 'true'; + const explicitMock = this.config.get('PAYMENT_MOCK_MODE'); + // بدون مرچنت ID، پیش‌فرض پرداخت سوری است (مناسب تست). PAYMENT_MOCK_MODE=false آن را خاموش می‌کند. + this.devMode = + explicitMock === 'true' || + (explicitMock !== 'false' && !this.merchantId); + if (this.devMode) { + this.logger.log('Payment mock mode is active (no real gateway)'); + } + } + + isMockMode(): boolean { + return this.devMode; + } + + isConfigured(): boolean { + return Boolean(this.merchantId) || this.devMode; + } + + getStartUrl(authority: string): string { + const host = this.sandbox ? 'https://sandbox.zarinpal.com' : 'https://www.zarinpal.com'; + return `${host}/pg/StartPay/${authority}`; + } + + async requestPayment(params: { + amountToman: number; + description: string; + callbackUrl: string; + mobile?: string | null; + }): Promise<{ authority: string; startUrl: string; mock?: boolean }> { + if (!this.devMode && params.amountToman < 1000) { + throw new BadRequestException('Payment amount must be at least 1000 toman'); + } + + if (this.devMode) { + const authority = `mock-${Date.now()}`; + return { authority, startUrl: '', mock: true }; + } + + if (!this.merchantId) { + throw new BadRequestException('Online payment is not configured'); + } + + const base = this.sandbox + ? 'https://sandbox.zarinpal.com/pg/v4/payment' + : 'https://api.zarinpal.com/pg/v4/payment'; + + const body = { + merchant_id: this.merchantId, + amount: params.amountToman, + callback_url: params.callbackUrl, + description: params.description, + metadata: params.mobile ? { mobile: params.mobile } : undefined, + }; + + const res = await fetch(`${base}/request.json`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + + const json = (await res.json()) as ZarinpalRequestResponse; + const code = json.data?.code; + const authority = json.data?.authority; + + if (code !== 100 || !authority) { + this.logger.warn(`Zarinpal request failed: ${JSON.stringify(json)}`); + throw new BadRequestException( + json.data?.message ?? 'Could not initiate online payment', + ); + } + + return { authority, startUrl: this.getStartUrl(authority) }; + } + + async verifyPayment(params: { + authority: string; + amountToman: number; + }): Promise<{ refId: string }> { + if (this.devMode && params.authority.startsWith('mock-')) { + return { refId: params.authority }; + } + + if (!this.merchantId) { + throw new BadRequestException('Online payment is not configured'); + } + + const base = this.sandbox + ? 'https://sandbox.zarinpal.com/pg/v4/payment' + : 'https://api.zarinpal.com/pg/v4/payment'; + + const res = await fetch(`${base}/verify.json`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + merchant_id: this.merchantId, + amount: params.amountToman, + authority: params.authority, + }), + }); + + const json = (await res.json()) as ZarinpalVerifyResponse; + const code = json.data?.code; + + if (code !== 100 && code !== 101) { + this.logger.warn(`Zarinpal verify failed: ${JSON.stringify(json)}`); + throw new BadRequestException( + json.data?.message ?? 'Payment verification failed', + ); + } + + return { refId: String(json.data?.ref_id ?? '') }; + } +}