awaiting_payment
This commit is contained in:
@@ -44,3 +44,12 @@ SMS_SENDER=
|
|||||||
|
|
||||||
# Optional: log Server-Timing to PM2 stdout (total;dur=… per request)
|
# Optional: log Server-Timing to PM2 stdout (total;dur=… per request)
|
||||||
# LOG_SERVER_TIMING=true
|
# 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 # فقط وقتی مرچنت دارید و میخواهید درگاه واقعی برود
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { UserEntity } from './user.entity';
|
|||||||
|
|
||||||
export enum BookingStatus {
|
export enum BookingStatus {
|
||||||
PENDING = 'pending',
|
PENDING = 'pending',
|
||||||
|
AWAITING_PAYMENT = 'awaiting_payment',
|
||||||
CONFIRMED = 'confirmed',
|
CONFIRMED = 'confirmed',
|
||||||
CANCELLED = 'cancelled',
|
CANCELLED = 'cancelled',
|
||||||
COMPLETED = 'completed',
|
COMPLETED = 'completed',
|
||||||
@@ -69,6 +70,16 @@ export class BookingEntity {
|
|||||||
@Column({ type: 'text', nullable: true })
|
@Column({ type: 'text', nullable: true })
|
||||||
cancellationReason!: string | null;
|
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()
|
@CreateDateColumn()
|
||||||
createdAt!: Date;
|
createdAt!: Date;
|
||||||
|
|
||||||
|
|||||||
@@ -58,6 +58,10 @@ export class BusinessEntity {
|
|||||||
@Column({ type: 'int', default: 30 })
|
@Column({ type: 'int', default: 30 })
|
||||||
slotStepMinutes!: number;
|
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, {
|
@ManyToOne(() => BusinessCategoryEntity, (category) => category.businesses, {
|
||||||
onDelete: 'RESTRICT',
|
onDelete: 'RESTRICT',
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import {
|
|||||||
Patch,
|
Patch,
|
||||||
Post,
|
Post,
|
||||||
Query,
|
Query,
|
||||||
|
Redirect,
|
||||||
UseGuards,
|
UseGuards,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ApiQuery } from '@nestjs/swagger';
|
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()
|
@Get()
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER)
|
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER, UserRole.CUSTOMER)
|
||||||
@@ -114,6 +148,13 @@ export class BookingsController {
|
|||||||
return this.service.confirm(id, user);
|
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')
|
@Post(':id/complete')
|
||||||
@UseGuards(JwtAuthGuard, RolesGuard)
|
@UseGuards(JwtAuthGuard, RolesGuard)
|
||||||
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
@Roles(UserRole.ADMIN, UserRole.BUSINESS_OWNER)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { BusinessEntity } from '../../entities/business.entity';
|
|||||||
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
|
import { ServiceTemplateEntity } from '../../entities/service-template.entity';
|
||||||
import { UserEntity } from '../../entities/user.entity';
|
import { UserEntity } from '../../entities/user.entity';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
|
import { PaymentsModule } from '../payments/payments.module';
|
||||||
import { BookingsController } from './bookings.controller';
|
import { BookingsController } from './bookings.controller';
|
||||||
import { BookingsService } from './bookings.service';
|
import { BookingsService } from './bookings.service';
|
||||||
|
|
||||||
@@ -25,6 +26,7 @@ import { BookingsService } from './bookings.service';
|
|||||||
ServiceTemplateEntity,
|
ServiceTemplateEntity,
|
||||||
]),
|
]),
|
||||||
AuthModule,
|
AuthModule,
|
||||||
|
PaymentsModule,
|
||||||
],
|
],
|
||||||
controllers: [BookingsController],
|
controllers: [BookingsController],
|
||||||
providers: [BookingsService],
|
providers: [BookingsService],
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import {
|
|||||||
BadRequestException,
|
BadRequestException,
|
||||||
ForbiddenException,
|
ForbiddenException,
|
||||||
Injectable,
|
Injectable,
|
||||||
|
Logger,
|
||||||
NotFoundException,
|
NotFoundException,
|
||||||
} from '@nestjs/common';
|
} from '@nestjs/common';
|
||||||
import { ConfigService } from '@nestjs/config';
|
import { ConfigService } from '@nestjs/config';
|
||||||
@@ -24,6 +25,8 @@ import { BusinessServiceEntity } from '../../entities/business-service.entity';
|
|||||||
import { BusinessEntity } from '../../entities/business.entity';
|
import { BusinessEntity } from '../../entities/business.entity';
|
||||||
import { UserEntity, UserRole } from '../../entities/user.entity';
|
import { UserEntity, UserRole } from '../../entities/user.entity';
|
||||||
import { AuthUser } from '../auth/auth.types';
|
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 { CreateBookingDto } from './dto/create-booking.dto';
|
||||||
import { CreateBookingBatchDto } from './dto/create-booking-batch.dto';
|
import { CreateBookingBatchDto } from './dto/create-booking-batch.dto';
|
||||||
import { UpdateBookingDto } from './dto/update-booking.dto';
|
import { UpdateBookingDto } from './dto/update-booking.dto';
|
||||||
@@ -37,10 +40,15 @@ type FindAllFilters = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class BookingsService {
|
export class BookingsService {
|
||||||
|
private readonly logger = new Logger(BookingsService.name);
|
||||||
private readonly minLeadMinutes: number;
|
private readonly minLeadMinutes: number;
|
||||||
|
private readonly webAppUrl: string;
|
||||||
|
private readonly apiPublicUrl: string;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly config: ConfigService,
|
private readonly config: ConfigService,
|
||||||
|
private readonly sms: SmsService,
|
||||||
|
private readonly zarinpal: ZarinpalService,
|
||||||
@InjectRepository(BookingEntity)
|
@InjectRepository(BookingEntity)
|
||||||
private readonly repo: Repository<BookingEntity>,
|
private readonly repo: Repository<BookingEntity>,
|
||||||
@InjectRepository(UserEntity)
|
@InjectRepository(UserEntity)
|
||||||
@@ -59,6 +67,13 @@ export class BookingsService {
|
|||||||
const raw = this.config.get<string | undefined>('BOOKING_MIN_LEAD_MINUTES');
|
const raw = this.config.get<string | undefined>('BOOKING_MIN_LEAD_MINUTES');
|
||||||
const parsed = raw !== undefined ? Number.parseInt(String(raw), 10) : NaN;
|
const parsed = raw !== undefined ? Number.parseInt(String(raw), 10) : NaN;
|
||||||
this.minLeadMinutes = Number.isFinite(parsed) && parsed >= 0 ? parsed : 60;
|
this.minLeadMinutes = Number.isFinite(parsed) && parsed >= 0 ? parsed : 60;
|
||||||
|
this.webAppUrl = (this.config.get<string>('WEB_APP_URL') ?? 'https://montime.ir').replace(
|
||||||
|
/\/$/,
|
||||||
|
'',
|
||||||
|
);
|
||||||
|
this.apiPublicUrl = (
|
||||||
|
this.config.get<string>('API_PUBLIC_URL') ?? 'https://api.montime.ir'
|
||||||
|
).replace(/\/$/, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
async findAll(
|
async findAll(
|
||||||
@@ -181,7 +196,11 @@ export class BookingsService {
|
|||||||
status: BookingStatus.PENDING,
|
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(
|
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(
|
async update(
|
||||||
@@ -311,10 +335,9 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async confirm(id: string, actor?: AuthUser): Promise<BookingEntity> {
|
async confirm(id: string, actor?: AuthUser): Promise<BookingEntity> {
|
||||||
const row = await this.findOne(id);
|
const row = await this.findOne(id, actor);
|
||||||
this.assertCanManageBooking(row, actor);
|
this.assertCanManageBooking(row, actor);
|
||||||
this.applyStatusTransition(row, BookingStatus.CONFIRMED, actor);
|
return this.approveForPayment(id, actor, { skipNotify: false });
|
||||||
return this.repo.save(row);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async complete(id: string, actor?: AuthUser): Promise<BookingEntity> {
|
async complete(id: string, actor?: AuthUser): Promise<BookingEntity> {
|
||||||
@@ -344,6 +367,120 @@ export class BookingsService {
|
|||||||
return this.repo.save(row);
|
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<void> {
|
async remove(id: string): Promise<void> {
|
||||||
const result = await this.repo.delete(id);
|
const result = await this.repo.delete(id);
|
||||||
if (!result.affected) {
|
if (!result.affected) {
|
||||||
@@ -465,7 +602,11 @@ export class BookingsService {
|
|||||||
.leftJoinAndSelect('bs.serviceTemplate', 'tpl')
|
.leftJoinAndSelect('bs.serviceTemplate', 'tpl')
|
||||||
.where('b.businessId = :businessId', { businessId })
|
.where('b.businessId = :businessId', { businessId })
|
||||||
.andWhere('b.status IN (:...statuses)', {
|
.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 >= :dayStart', { dayStart })
|
||||||
.andWhere('b.scheduledAt <= :dayEnd', { dayEnd });
|
.andWhere('b.scheduledAt <= :dayEnd', { dayEnd });
|
||||||
@@ -595,7 +736,11 @@ export class BookingsService {
|
|||||||
const siblings = await this.repo.find({
|
const siblings = await this.repo.find({
|
||||||
where: {
|
where: {
|
||||||
bookingGroupId: booking.bookingGroupId,
|
bookingGroupId: booking.bookingGroupId,
|
||||||
status: In([BookingStatus.PENDING, BookingStatus.CONFIRMED]),
|
status: In([
|
||||||
|
BookingStatus.PENDING,
|
||||||
|
BookingStatus.AWAITING_PAYMENT,
|
||||||
|
BookingStatus.CONFIRMED,
|
||||||
|
]),
|
||||||
},
|
},
|
||||||
relations: { businessService: { serviceTemplate: true } },
|
relations: { businessService: { serviceTemplate: true } },
|
||||||
});
|
});
|
||||||
@@ -762,13 +907,23 @@ export class BookingsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (nextStatus === BookingStatus.CONFIRMED) {
|
if (nextStatus === BookingStatus.CONFIRMED) {
|
||||||
if (row.status !== BookingStatus.PENDING) {
|
if (row.status !== BookingStatus.AWAITING_PAYMENT) {
|
||||||
throw new BadRequestException('Only pending bookings can be confirmed');
|
throw new BadRequestException(
|
||||||
|
'Only bookings awaiting payment can be confirmed',
|
||||||
|
);
|
||||||
}
|
}
|
||||||
row.status = BookingStatus.CONFIRMED;
|
row.status = BookingStatus.CONFIRMED;
|
||||||
return;
|
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 (nextStatus === BookingStatus.COMPLETED) {
|
||||||
if (row.status !== BookingStatus.CONFIRMED) {
|
if (row.status !== BookingStatus.CONFIRMED) {
|
||||||
throw new BadRequestException('Only confirmed bookings can be completed');
|
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');
|
throw new ForbiddenException('You can only manage your own business bookings');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async approveForPayment(
|
||||||
|
id: string,
|
||||||
|
actor?: AuthUser,
|
||||||
|
options?: { skipNotify?: boolean },
|
||||||
|
): Promise<BookingEntity> {
|
||||||
|
return this.approveForPaymentGroup(id, actor, options);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async approveForPaymentGroup(
|
||||||
|
id: string,
|
||||||
|
actor?: AuthUser,
|
||||||
|
options?: { skipNotify?: boolean },
|
||||||
|
): Promise<BookingEntity> {
|
||||||
|
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<BookingEntity[]> {
|
||||||
|
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<number> {
|
||||||
|
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<number> {
|
||||||
|
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<void> {
|
||||||
|
for (const row of group) {
|
||||||
|
row.status = BookingStatus.CONFIRMED;
|
||||||
|
row.paymentAuthority = authority;
|
||||||
|
}
|
||||||
|
await this.repo.save(group);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async notifyPaymentReady(group: BookingEntity[]): Promise<void> {
|
||||||
|
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}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -109,4 +109,13 @@ export class UpdateBusinessDto {
|
|||||||
@Min(5)
|
@Min(5)
|
||||||
@Max(120)
|
@Max(120)
|
||||||
slotStepMinutes?: number;
|
slotStepMinutes?: number;
|
||||||
|
|
||||||
|
@ApiPropertyOptional({
|
||||||
|
example: false,
|
||||||
|
description:
|
||||||
|
'When true, customer bookings are approved automatically and proceed to online payment',
|
||||||
|
})
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
autoApproveBookings?: boolean;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
import { Module } from '@nestjs/common';
|
||||||
|
import { ZarinpalService } from './zarinpal.service';
|
||||||
|
|
||||||
|
@Module({
|
||||||
|
providers: [ZarinpalService],
|
||||||
|
exports: [ZarinpalService],
|
||||||
|
})
|
||||||
|
export class PaymentsModule {}
|
||||||
@@ -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<string>('ZARINPAL_MERCHANT_ID') ?? '';
|
||||||
|
this.sandbox = this.config.get<string>('ZARINPAL_SANDBOX') === 'true';
|
||||||
|
const explicitMock = this.config.get<string>('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 ?? '') };
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user