Deposit
This commit is contained in:
@@ -0,0 +1,386 @@
|
||||
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 { 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;
|
||||
|
||||
@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.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 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 tomanToRial(amountToman: number): number {
|
||||
return Math.round(amountToman * 10);
|
||||
}
|
||||
|
||||
private rialToToman(amountRial: number): number {
|
||||
return Math.round(amountRial / 10);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user