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
+9
View File
@@ -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 # فقط وقتی مرچنت دارید و می‌خواهید درگاه واقعی برود
+11
View File
@@ -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;
+4
View File
@@ -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',
})
@@ -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)
+2
View File
@@ -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],
+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}`,
);
}
}
@@ -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;
}
+8
View File
@@ -0,0 +1,8 @@
import { Module } from '@nestjs/common';
import { ZarinpalService } from './zarinpal.service';
@Module({
providers: [ZarinpalService],
exports: [ZarinpalService],
})
export class PaymentsModule {}
+149
View File
@@ -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 ?? '') };
}
}