453 lines
14 KiB
TypeScript
453 lines
14 KiB
TypeScript
import {
|
|
BadRequestException,
|
|
ForbiddenException,
|
|
forwardRef,
|
|
Inject,
|
|
Injectable,
|
|
Logger,
|
|
NotFoundException,
|
|
} from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import { InjectRepository } from '@nestjs/typeorm';
|
|
import { randomUUID } from 'crypto';
|
|
import { Repository } from 'typeorm';
|
|
import { AuthUser } from '../auth/interfaces/auth-user.interface';
|
|
import { AuthorizationService } from '../authorization/authorization.service';
|
|
import { ReservePolicy } from '../authorization/policies/reserve.policy';
|
|
import { SalonNotificationsService } from '../notifications/salon-notifications.service';
|
|
import { ReserveStatus } from '../reserves/enums/reserve-status.enum';
|
|
import { ReservesService } from '../reserves/reserves.service';
|
|
import { calculateSmsParts } from '../sms/helpers/sms-segment.helper';
|
|
import { SmsService } from '../sms/sms.service';
|
|
import { SmsDispatchService } from '../sms-settings/sms-dispatch.service';
|
|
import { SmsScenarioKey } from '../sms-settings/enums/sms-scenario-key.enum';
|
|
import { buildReserveSmsVariables } from '../sms-settings/utils/build-reserve-sms-variables.util';
|
|
import { SalonFinancialSettingsService } from '../salons/salon-financial-settings.service';
|
|
import { SalonSubscriptionsService } from '../subscriptions/salon-subscriptions.service';
|
|
import { UploaderService } from '../uploader/uploader.service';
|
|
import { UserRole } from '../users/enums/user-role.enum';
|
|
import { ShortLinksService } from '../short-links/short-links.service';
|
|
import { DepositListItemDto } from './dto/deposit-list-item.dto';
|
|
import { FindDepositsQueryDto } from './dto/find-deposits-query.dto';
|
|
import { PublicDepositPaymentDto } from './dto/public-deposit-payment.dto';
|
|
import { ReviewDepositDto } from './dto/review-deposit.dto';
|
|
import { Payment } from './entities/payment.entity';
|
|
import { PaymentMethod } from './enums/payment-method.enum';
|
|
import { PaymentStatus } from './enums/payment-status.enum';
|
|
import { PaymentType } from './enums/payment-type.enum';
|
|
|
|
const MIN_AMOUNT_TOMAN = 100;
|
|
/** Gives the reserve-created SMS time to reach the customer before the deposit link SMS. */
|
|
const DEPOSIT_SMS_DELAY_MS = 2_500;
|
|
|
|
@Injectable()
|
|
export class CardToCardDepositService {
|
|
private readonly logger = new Logger(CardToCardDepositService.name);
|
|
private readonly frontendUrl: string;
|
|
|
|
constructor(
|
|
@InjectRepository(Payment)
|
|
private readonly paymentsRepository: Repository<Payment>,
|
|
private readonly reservesService: ReservesService,
|
|
private readonly reservePolicy: ReservePolicy,
|
|
private readonly authorizationService: AuthorizationService,
|
|
private readonly salonFinancialSettingsService: SalonFinancialSettingsService,
|
|
private readonly uploaderService: UploaderService,
|
|
private readonly smsDispatchService: SmsDispatchService,
|
|
private readonly smsService: SmsService,
|
|
private readonly shortLinksService: ShortLinksService,
|
|
private readonly salonNotificationsService: SalonNotificationsService,
|
|
@Inject(forwardRef(() => SalonSubscriptionsService))
|
|
private readonly salonSubscriptionsService: SalonSubscriptionsService,
|
|
private readonly configService: ConfigService,
|
|
) {
|
|
this.frontendUrl = (
|
|
this.configService.get<string>('FRONTEND_URL') ?? 'http://localhost:3000'
|
|
).replace(/\/$/, '');
|
|
}
|
|
|
|
async requestDeposit(reserveId: string, user: AuthUser): Promise<Payment> {
|
|
if (user.role !== UserRole.SALON && user.role !== UserRole.ADMIN) {
|
|
throw new ForbiddenException();
|
|
}
|
|
|
|
const reserve = await this.reservesService.findOne(reserveId, user);
|
|
if (!this.reservePolicy.canRead(user, reserve)) {
|
|
throw new ForbiddenException();
|
|
}
|
|
|
|
if (reserve.status !== ReserveStatus.PENDING) {
|
|
throw new BadRequestException(
|
|
'Only pending reserves can be paid for deposit',
|
|
);
|
|
}
|
|
|
|
if (reserve.depositAmount <= 0) {
|
|
throw new BadRequestException('This reserve has no deposit amount');
|
|
}
|
|
|
|
if (reserve.depositAmount < MIN_AMOUNT_TOMAN) {
|
|
throw new BadRequestException(
|
|
`Minimum deposit amount is ${MIN_AMOUNT_TOMAN} Toman`,
|
|
);
|
|
}
|
|
|
|
const existingPaid = await this.paymentsRepository.existsBy({
|
|
reserveId,
|
|
type: PaymentType.DEPOSIT,
|
|
status: PaymentStatus.PAID,
|
|
});
|
|
if (existingPaid) {
|
|
throw new BadRequestException('Deposit has already been paid');
|
|
}
|
|
|
|
// Ensures the salon has configured a card before a request is sent to the customer.
|
|
await this.salonFinancialSettingsService.findBySalonId(reserve.salonId);
|
|
|
|
const existingPending = await this.paymentsRepository.findOne({
|
|
where: {
|
|
reserveId,
|
|
type: PaymentType.DEPOSIT,
|
|
method: PaymentMethod.CARD_TO_CARD,
|
|
},
|
|
order: { createdAt: 'DESC' },
|
|
});
|
|
|
|
if (existingPending?.status === PaymentStatus.IN_REVIEW) {
|
|
throw new BadRequestException(
|
|
'رسید پرداخت قبلی در انتظار بررسی است',
|
|
);
|
|
}
|
|
|
|
const payment =
|
|
existingPending &&
|
|
[PaymentStatus.PENDING, PaymentStatus.REJECTED].includes(
|
|
existingPending.status,
|
|
)
|
|
? existingPending
|
|
: this.paymentsRepository.create({
|
|
type: PaymentType.DEPOSIT,
|
|
method: PaymentMethod.CARD_TO_CARD,
|
|
amountRial: this.tomanToRial(reserve.depositAmount),
|
|
status: PaymentStatus.PENDING,
|
|
description: `پرداخت کارت به کارت بیعانه رزرو ${reserve.id}`,
|
|
reserveId: reserve.id,
|
|
publicToken: randomUUID(),
|
|
});
|
|
|
|
if (payment.status === PaymentStatus.REJECTED) {
|
|
payment.status = PaymentStatus.PENDING;
|
|
payment.rejectionReason = null;
|
|
payment.receiptUrl = null;
|
|
}
|
|
|
|
const saved = await this.paymentsRepository.save(payment);
|
|
|
|
const paymentLink = await this.shortLinksService.createShortUrl(
|
|
`${this.frontendUrl}/pay/${saved.publicToken}`,
|
|
);
|
|
|
|
const variables = buildReserveSmsVariables(reserve);
|
|
const mobile = reserve.customer?.user?.mobile;
|
|
if (mobile) {
|
|
await this.delay(DEPOSIT_SMS_DELAY_MS);
|
|
|
|
await this.sendDepositSms(
|
|
SmsScenarioKey.DEPOSIT_PAYMENT_REQUEST,
|
|
mobile,
|
|
{ ...variables, paymentLink },
|
|
reserve.salonId,
|
|
);
|
|
}
|
|
|
|
return saved;
|
|
}
|
|
|
|
async getPublicPaymentByToken(token: string): Promise<PublicDepositPaymentDto> {
|
|
const payment = await this.paymentsRepository.findOne({
|
|
where: { publicToken: token },
|
|
relations: { reserve: { salon: true } },
|
|
});
|
|
|
|
if (!payment || !payment.reserve) {
|
|
throw new NotFoundException('لینک پرداخت یافت نشد');
|
|
}
|
|
|
|
const financial = await this.salonFinancialSettingsService.findBySalonId(
|
|
payment.reserve.salonId,
|
|
);
|
|
|
|
return {
|
|
paymentId: payment.id,
|
|
status: payment.status,
|
|
amountToman: this.rialToToman(payment.amountRial),
|
|
salon: {
|
|
name: payment.reserve.salon.name,
|
|
address: payment.reserve.salon.address,
|
|
images: payment.reserve.salon.images ?? [],
|
|
},
|
|
card: {
|
|
cardNumber: financial.cardNumber,
|
|
cardHolderName: financial.cardHolderName,
|
|
},
|
|
appointmentDate: payment.reserve.appointmentDate ?? null,
|
|
startTime: payment.reserve.startTime ?? null,
|
|
rejectionReason: payment.rejectionReason,
|
|
};
|
|
}
|
|
|
|
async submitReceipt(
|
|
token: string,
|
|
file: Express.Multer.File,
|
|
): Promise<PublicDepositPaymentDto> {
|
|
const payment = await this.paymentsRepository.findOne({
|
|
where: { publicToken: token },
|
|
relations: {
|
|
reserve: { salon: { owner: true }, customer: { user: true } },
|
|
},
|
|
});
|
|
|
|
if (!payment || !payment.reserve) {
|
|
throw new NotFoundException('لینک پرداخت یافت نشد');
|
|
}
|
|
|
|
if (
|
|
payment.status !== PaymentStatus.PENDING &&
|
|
payment.status !== PaymentStatus.REJECTED
|
|
) {
|
|
throw new BadRequestException(
|
|
'این پرداخت قبلاً ثبت شده و در انتظار بررسی یا تأیید است',
|
|
);
|
|
}
|
|
|
|
const uploaded = await this.uploaderService.uploadSingle(file);
|
|
|
|
payment.receiptUrl = uploaded.url;
|
|
payment.status = PaymentStatus.IN_REVIEW;
|
|
payment.rejectionReason = null;
|
|
const saved = await this.paymentsRepository.save(payment);
|
|
|
|
const reserve = payment.reserve;
|
|
const ownerMobile = reserve.salon?.owner?.mobile;
|
|
const variables = buildReserveSmsVariables(reserve);
|
|
|
|
if (ownerMobile) {
|
|
const reviewLink = await this.shortLinksService.createShortUrl(
|
|
`${this.frontendUrl}/deposits/${saved.id}/review`,
|
|
);
|
|
await this.sendDepositSms(
|
|
SmsScenarioKey.DEPOSIT_RECEIPT_SUBMITTED,
|
|
ownerMobile,
|
|
{ ...variables, reviewLink },
|
|
reserve.salonId,
|
|
);
|
|
}
|
|
|
|
await this.salonNotificationsService.notifyDepositReceiptSubmitted({
|
|
salonId: reserve.salonId,
|
|
salonOwnerId: reserve.salon.ownerId,
|
|
salonName: reserve.salon.name,
|
|
customerName: variables.customerName,
|
|
depositAmount: reserve.depositAmount,
|
|
paymentId: saved.id,
|
|
});
|
|
|
|
return this.getPublicPaymentByToken(token);
|
|
}
|
|
|
|
async findForSalon(
|
|
user: AuthUser,
|
|
query: FindDepositsQueryDto,
|
|
): Promise<DepositListItemDto[]> {
|
|
if (user.role !== UserRole.SALON && user.role !== UserRole.ADMIN) {
|
|
throw new ForbiddenException();
|
|
}
|
|
|
|
const qb = this.paymentsRepository
|
|
.createQueryBuilder('payment')
|
|
.innerJoinAndSelect('payment.reserve', 'reserve')
|
|
.innerJoinAndSelect('reserve.salon', 'salon')
|
|
.leftJoinAndSelect('reserve.customer', 'customer')
|
|
.leftJoinAndSelect('customer.user', 'customerUser')
|
|
.where('payment.type = :type', { type: PaymentType.DEPOSIT })
|
|
.andWhere('payment.method = :method', {
|
|
method: PaymentMethod.CARD_TO_CARD,
|
|
})
|
|
.orderBy('payment.createdAt', 'DESC');
|
|
|
|
if (query.status) {
|
|
qb.andWhere('payment.status = :status', { status: query.status });
|
|
}
|
|
|
|
if (!this.authorizationService.isAdmin(user)) {
|
|
qb.andWhere('salon.ownerId = :ownerId', { ownerId: user.id });
|
|
}
|
|
|
|
const limit = query.limit ?? 50;
|
|
const offset = query.offset ?? 0;
|
|
qb.take(limit).skip(offset);
|
|
|
|
const payments = await qb.getMany();
|
|
|
|
return payments.map((payment) => this.toDepositListItem(payment));
|
|
}
|
|
|
|
async findForReview(paymentId: string, user: AuthUser): Promise<Payment> {
|
|
const payment = await this.loadPaymentWithRelations(paymentId);
|
|
this.assertCanReview(user, payment);
|
|
return payment;
|
|
}
|
|
|
|
async reviewDeposit(
|
|
paymentId: string,
|
|
dto: ReviewDepositDto,
|
|
user: AuthUser,
|
|
): Promise<Payment> {
|
|
const payment = await this.loadPaymentWithRelations(paymentId);
|
|
this.assertCanReview(user, payment);
|
|
|
|
if (payment.status !== PaymentStatus.IN_REVIEW) {
|
|
throw new BadRequestException(
|
|
'این پرداخت در وضعیت قابل بررسی نیست',
|
|
);
|
|
}
|
|
|
|
// Guaranteed non-null by loadPaymentWithRelations().
|
|
const reserve = payment.reserve!;
|
|
const variables = buildReserveSmsVariables(reserve);
|
|
const customerMobile = reserve.customer?.user?.mobile;
|
|
|
|
if (dto.decision === 'approve') {
|
|
payment.status = PaymentStatus.PAID;
|
|
payment.reviewedAt = new Date();
|
|
payment.reviewedByUserId = user.id;
|
|
const saved = await this.paymentsRepository.save(payment);
|
|
|
|
await this.reservesService.confirmDepositPayment(reserve.id, user.id);
|
|
|
|
if (customerMobile) {
|
|
await this.sendDepositSms(
|
|
SmsScenarioKey.DEPOSIT_APPROVED,
|
|
customerMobile,
|
|
variables,
|
|
reserve.salonId,
|
|
);
|
|
}
|
|
|
|
return saved;
|
|
}
|
|
|
|
payment.status = PaymentStatus.REJECTED;
|
|
payment.rejectionReason = dto.rejectionReason ?? null;
|
|
payment.reviewedAt = new Date();
|
|
payment.reviewedByUserId = user.id;
|
|
const saved = await this.paymentsRepository.save(payment);
|
|
|
|
if (customerMobile) {
|
|
const paymentLink = await this.shortLinksService.createShortUrl(
|
|
`${this.frontendUrl}/pay/${saved.publicToken}`,
|
|
);
|
|
await this.sendDepositSms(
|
|
SmsScenarioKey.DEPOSIT_REJECTED,
|
|
customerMobile,
|
|
{
|
|
...variables,
|
|
rejectionReason: dto.rejectionReason ?? '—',
|
|
paymentLink,
|
|
},
|
|
reserve.salonId,
|
|
);
|
|
}
|
|
|
|
return saved;
|
|
}
|
|
|
|
private async loadPaymentWithRelations(paymentId: string): Promise<Payment> {
|
|
const payment = await this.paymentsRepository.findOne({
|
|
where: { id: paymentId },
|
|
relations: {
|
|
reserve: { salon: true, customer: { user: true } },
|
|
},
|
|
});
|
|
|
|
if (!payment || !payment.reserve) {
|
|
throw new NotFoundException(`Payment #${paymentId} not found`);
|
|
}
|
|
|
|
return payment;
|
|
}
|
|
|
|
private assertCanReview(user: AuthUser, payment: Payment): void {
|
|
if (this.authorizationService.isAdmin(user)) {
|
|
return;
|
|
}
|
|
|
|
if (
|
|
user.role === UserRole.SALON &&
|
|
payment.reserve?.salon.ownerId === user.id
|
|
) {
|
|
return;
|
|
}
|
|
|
|
throw new ForbiddenException();
|
|
}
|
|
|
|
/** Resolves the scenario message, deducts the salon's SMS balance, then sends it. */
|
|
private async sendDepositSms(
|
|
key: SmsScenarioKey,
|
|
mobile: string,
|
|
variables: Record<string, string>,
|
|
salonId: string,
|
|
): Promise<boolean> {
|
|
const message = await this.smsDispatchService.resolveMessage(key, variables);
|
|
if (!message) {
|
|
return false;
|
|
}
|
|
|
|
try {
|
|
await this.salonSubscriptionsService.consumeSms(
|
|
salonId,
|
|
calculateSmsParts(message),
|
|
);
|
|
} catch (error) {
|
|
this.logger.warn(
|
|
`Skipping deposit SMS "${key}" for salon ${salonId}: ${(error as Error).message}`,
|
|
);
|
|
return false;
|
|
}
|
|
|
|
return this.smsService.sendSms(mobile, message);
|
|
}
|
|
|
|
private delay(ms: number): Promise<void> {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
private tomanToRial(amountToman: number): number {
|
|
return Math.round(amountToman * 10);
|
|
}
|
|
|
|
private rialToToman(amountRial: number): number {
|
|
return Math.round(amountRial / 10);
|
|
}
|
|
|
|
private toDepositListItem(payment: Payment): DepositListItemDto {
|
|
const user = payment.reserve?.customer?.user;
|
|
const customerName = user
|
|
? `${user.firstName} ${user.lastName}`.trim()
|
|
: 'مشتری';
|
|
|
|
return {
|
|
id: payment.id,
|
|
status: payment.status,
|
|
amountToman: this.rialToToman(payment.amountRial),
|
|
receiptUrl: payment.receiptUrl,
|
|
createdAt: payment.createdAt,
|
|
customerName,
|
|
customerMobile: user?.mobile ?? null,
|
|
appointmentDate: payment.reserve?.appointmentDate ?? null,
|
|
startTime: payment.reserve?.startTime ?? null,
|
|
};
|
|
}
|
|
}
|