Files
grow-api/src/payments/card-to-card-deposit.service.ts
T
2026-07-11 20:56:38 +03:30

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,
};
}
}