awaiting_payment
This commit is contained in:
@@ -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<BookingEntity>,
|
||||
@InjectRepository(UserEntity)
|
||||
@@ -59,6 +67,13 @@ export class BookingsService {
|
||||
const raw = this.config.get<string | undefined>('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<string>('WEB_APP_URL') ?? 'https://montime.ir').replace(
|
||||
/\/$/,
|
||||
'',
|
||||
);
|
||||
this.apiPublicUrl = (
|
||||
this.config.get<string>('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<BookingEntity> {
|
||||
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<BookingEntity> {
|
||||
@@ -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<void> {
|
||||
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<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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user