awaiting_payment

This commit is contained in:
2026-06-29 15:46:24 +03:30
parent bb5f2ef2b3
commit 2eb6718bf4
9 changed files with 536 additions and 9 deletions
+303 -9
View File
@@ -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}`,
);
}
}