awaiting_payment
This commit is contained in:
@@ -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 # فقط وقتی مرچنت دارید و میخواهید درگاه واقعی برود
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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