diff --git a/.env.example b/.env.example index 0faf84f..c72e7b8 100644 --- a/.env.example +++ b/.env.example @@ -33,6 +33,10 @@ NODE_ENV=development # CORS (comma-separated origins, e.g. https://app.example.com) # CORS_ORIGIN= +# Public URLs used to build short links sent by SMS (e.g. deposit payment/review links) +FRONTEND_URL=http://localhost:3000 +API_PUBLIC_URL=http://localhost:4000 + # Zarinpal Payment Gateway ZARINPAL_MERCHANT_ID=8bf4bd49-df53-44b3-a58d-b068d4c1ec98 ZARINPAL_SANDBOX=true diff --git a/src/app.module.ts b/src/app.module.ts index abf735b..c3126f0 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -23,6 +23,7 @@ import { ProvincesModule } from './provinces/provinces.module'; import { UploaderModule } from './uploader/uploader.module'; import { DashboardModule } from './dashboard/dashboard.module'; import { NotificationsModule } from './notifications/notifications.module'; +import { ShortLinksModule } from './short-links/short-links.module'; @Module({ imports: [ @@ -51,6 +52,7 @@ import { NotificationsModule } from './notifications/notifications.module'; UploaderModule, DashboardModule, NotificationsModule, + ShortLinksModule, ], controllers: [], providers: [], diff --git a/src/config/configuration.ts b/src/config/configuration.ts index d76ee43..de85fc6 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -39,5 +39,7 @@ export default () => { process.env.S3_PUBLIC_BASE_URL ?? process.env.S3_ENDPOINT ?? 'https://c274938.parspack.net', + FRONTEND_URL: process.env.FRONTEND_URL ?? 'http://localhost:3000', + API_PUBLIC_URL: process.env.API_PUBLIC_URL ?? 'http://localhost:4000', }; }; diff --git a/src/database/migrations/1721600000000-AddSalonImages.ts b/src/database/migrations/1721600000000-AddSalonImages.ts new file mode 100644 index 0000000..733f019 --- /dev/null +++ b/src/database/migrations/1721600000000-AddSalonImages.ts @@ -0,0 +1,20 @@ +import { MigrationInterface, QueryRunner, TableColumn } from 'typeorm'; + +export class AddSalonImages1721600000000 implements MigrationInterface { + name = 'AddSalonImages1721600000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.addColumn( + 'salons', + new TableColumn({ + name: 'images', + type: 'jsonb', + default: `'[]'`, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropColumn('salons', 'images'); + } +} diff --git a/src/database/migrations/1721700000000-AddSalonFinancialSettings.ts b/src/database/migrations/1721700000000-AddSalonFinancialSettings.ts new file mode 100644 index 0000000..ec47cc4 --- /dev/null +++ b/src/database/migrations/1721700000000-AddSalonFinancialSettings.ts @@ -0,0 +1,60 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, +} from 'typeorm'; + +export class AddSalonFinancialSettings1721700000000 + implements MigrationInterface +{ + name = 'AddSalonFinancialSettings1721700000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'salon_financial_settings', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'salonId', type: 'uuid' }, + { name: 'cardNumber', type: 'varchar', length: '19' }, + { name: 'cardHolderName', type: 'varchar', length: '100' }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'salon_financial_settings', + new TableIndex({ + name: 'IDX_salon_financial_settings_salonId', + columnNames: ['salonId'], + isUnique: true, + }), + ); + + await queryRunner.createForeignKey( + 'salon_financial_settings', + new TableForeignKey({ + name: 'FK_salon_financial_settings_salonId', + columnNames: ['salonId'], + referencedTableName: 'salons', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('salon_financial_settings', true); + } +} diff --git a/src/database/migrations/1721800000000-AddShortLinks.ts b/src/database/migrations/1721800000000-AddShortLinks.ts new file mode 100644 index 0000000..07a8abd --- /dev/null +++ b/src/database/migrations/1721800000000-AddShortLinks.ts @@ -0,0 +1,41 @@ +import { MigrationInterface, QueryRunner, Table, TableIndex } from 'typeorm'; + +export class AddShortLinks1721800000000 implements MigrationInterface { + name = 'AddShortLinks1721800000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.createTable( + new Table({ + name: 'short_links', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'code', type: 'varchar', length: '12' }, + { name: 'targetUrl', type: 'text' }, + { name: 'clickCount', type: 'int', default: 0 }, + { name: 'expiresAt', type: 'timestamp', isNullable: true }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createIndex( + 'short_links', + new TableIndex({ + name: 'IDX_short_links_code', + columnNames: ['code'], + isUnique: true, + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('short_links', true); + } +} diff --git a/src/database/migrations/1721900000000-AddCardToCardDeposit.ts b/src/database/migrations/1721900000000-AddCardToCardDeposit.ts new file mode 100644 index 0000000..2a6b6b5 --- /dev/null +++ b/src/database/migrations/1721900000000-AddCardToCardDeposit.ts @@ -0,0 +1,91 @@ +import { + MigrationInterface, + QueryRunner, + TableColumn, + TableForeignKey, + TableIndex, +} from 'typeorm'; + +export class AddCardToCardDeposit1721900000000 implements MigrationInterface { + name = 'AddCardToCardDeposit1721900000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TYPE "payments_status_enum" ADD VALUE IF NOT EXISTS 'in_review' + `); + await queryRunner.query(` + ALTER TYPE "payments_status_enum" ADD VALUE IF NOT EXISTS 'rejected' + `); + + await queryRunner.query(` + CREATE TYPE "payments_method_enum" AS ENUM('zarinpal', 'card_to_card') + `); + + await queryRunner.addColumns('payments', [ + new TableColumn({ + name: 'method', + type: 'payments_method_enum', + default: `'zarinpal'`, + }), + new TableColumn({ + name: 'publicToken', + type: 'varchar', + length: '64', + isNullable: true, + }), + new TableColumn({ + name: 'receiptUrl', + type: 'text', + isNullable: true, + }), + new TableColumn({ + name: 'rejectionReason', + type: 'text', + isNullable: true, + }), + new TableColumn({ + name: 'reviewedAt', + type: 'timestamp', + isNullable: true, + }), + new TableColumn({ + name: 'reviewedByUserId', + type: 'uuid', + isNullable: true, + }), + ]); + + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_payments_publicToken" + ON "payments" ("publicToken") + WHERE "publicToken" IS NOT NULL + `); + + await queryRunner.createForeignKey( + 'payments', + new TableForeignKey({ + name: 'FK_payments_reviewedByUserId', + columnNames: ['reviewedByUserId'], + referencedTableName: 'users', + referencedColumnNames: ['id'], + onDelete: 'SET NULL', + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropForeignKey('payments', 'FK_payments_reviewedByUserId'); + await queryRunner.dropIndex('payments', 'IDX_payments_publicToken'); + await queryRunner.dropColumns('payments', [ + 'reviewedByUserId', + 'reviewedAt', + 'rejectionReason', + 'receiptUrl', + 'publicToken', + 'method', + ]); + await queryRunner.query(`DROP TYPE "payments_method_enum"`); + // Note: Postgres does not support removing enum values; 'in_review' and + // 'rejected' remain in "payments_status_enum" after rollback. + } +} diff --git a/src/database/migrations/1722000000000-AddDepositScenarios.ts b/src/database/migrations/1722000000000-AddDepositScenarios.ts new file mode 100644 index 0000000..2a1741e --- /dev/null +++ b/src/database/migrations/1722000000000-AddDepositScenarios.ts @@ -0,0 +1,40 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { SmsScenarioKey } from '../../sms-settings/enums/sms-scenario-key.enum'; +import { NotificationScenarioKey } from '../../notifications/enums/notification-scenario-key.enum'; + +const NEW_SMS_KEYS = [ + SmsScenarioKey.DEPOSIT_PAYMENT_REQUEST, + SmsScenarioKey.DEPOSIT_RECEIPT_SUBMITTED, + SmsScenarioKey.DEPOSIT_APPROVED, + SmsScenarioKey.DEPOSIT_REJECTED, +]; + +const NEW_NOTIFICATION_KEYS = [NotificationScenarioKey.DEPOSIT_RECEIPT_SUBMITTED]; + +/** + * Only adds the new enum values. Postgres forbids using a newly added enum + * value in the same transaction it was created in, so seeding the actual + * scenario rows is handled by the follow-up `SeedDepositScenarios` migration. + */ +export class AddDepositScenarios1722000000000 implements MigrationInterface { + name = 'AddDepositScenarios1722000000000'; + + public async up(queryRunner: QueryRunner): Promise { + for (const key of NEW_SMS_KEYS) { + await queryRunner.query( + `ALTER TYPE "sms_scenario_key_enum" ADD VALUE IF NOT EXISTS '${key}'`, + ); + } + + for (const key of NEW_NOTIFICATION_KEYS) { + await queryRunner.query( + `ALTER TYPE "notification_scenario_key_enum" ADD VALUE IF NOT EXISTS '${key}'`, + ); + } + } + + public async down(): Promise { + // Enum values cannot be dropped without recreating the type; left as-is on downgrade. + // Seeded rows (if any) are removed by SeedDepositScenarios's down(). + } +} diff --git a/src/database/migrations/1722100000000-SeedDepositScenarios.ts b/src/database/migrations/1722100000000-SeedDepositScenarios.ts new file mode 100644 index 0000000..11ba782 --- /dev/null +++ b/src/database/migrations/1722100000000-SeedDepositScenarios.ts @@ -0,0 +1,56 @@ +import { MigrationInterface, QueryRunner } from 'typeorm'; +import { SMS_SCENARIOS } from '../../sms-settings/constants/sms-scenarios.constants'; +import { SmsScenarioKey } from '../../sms-settings/enums/sms-scenario-key.enum'; +import { NOTIFICATION_SCENARIOS } from '../../notifications/constants/notification-scenarios.constants'; +import { NotificationScenarioKey } from '../../notifications/enums/notification-scenario-key.enum'; + +const NEW_SMS_KEYS = [ + SmsScenarioKey.DEPOSIT_PAYMENT_REQUEST, + SmsScenarioKey.DEPOSIT_RECEIPT_SUBMITTED, + SmsScenarioKey.DEPOSIT_APPROVED, + SmsScenarioKey.DEPOSIT_REJECTED, +]; + +const NEW_NOTIFICATION_KEYS = [NotificationScenarioKey.DEPOSIT_RECEIPT_SUBMITTED]; + +/** Seeds the default scenario rows for the enum values added in AddDepositScenarios. */ +export class SeedDepositScenarios1722100000000 implements MigrationInterface { + name = 'SeedDepositScenarios1722100000000'; + + public async up(queryRunner: QueryRunner): Promise { + for (const key of NEW_SMS_KEYS) { + const scenario = SMS_SCENARIOS.find((s) => s.key === key); + if (!scenario) continue; + await queryRunner.query( + ` + INSERT INTO sms_scenario_settings (key, body, "isActive") + VALUES ($1, $2, true) + ON CONFLICT (key) DO NOTHING + `, + [scenario.key, scenario.defaultBody], + ); + } + + for (const key of NEW_NOTIFICATION_KEYS) { + const scenario = NOTIFICATION_SCENARIOS.find((s) => s.key === key); + if (!scenario) continue; + await queryRunner.query( + ` + INSERT INTO notification_scenario_settings (key, title, body, "isActive") + VALUES ($1, $2, $3, true) + ON CONFLICT (key) DO NOTHING + `, + [scenario.key, scenario.defaultTitle, scenario.defaultBody], + ); + } + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.query( + `DELETE FROM "sms_scenario_settings" WHERE "key" IN (${NEW_SMS_KEYS.map((key) => `'${key}'`).join(', ')})`, + ); + await queryRunner.query( + `DELETE FROM "notification_scenario_settings" WHERE "key" IN (${NEW_NOTIFICATION_KEYS.map((key) => `'${key}'`).join(', ')})`, + ); + } +} diff --git a/src/main.ts b/src/main.ts index 99e0bed..5c6feff 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,4 +1,4 @@ -import { ValidationPipe } from '@nestjs/common'; +import { RequestMethod, ValidationPipe } from '@nestjs/common'; import { NestFactory } from '@nestjs/core'; import { AppModule } from './app.module'; import { ApiExceptionFilter } from './common/filters/api-exception.filter'; @@ -20,8 +20,10 @@ async function bootstrap() { }), ); - // Global prefix - app.setGlobalPrefix('api'); + // Global prefix (short links stay outside "api" so shared URLs are shorter) + app.setGlobalPrefix('api', { + exclude: [{ path: 's/:code', method: RequestMethod.GET }], + }); setupSwagger(app); diff --git a/src/notifications/constants/notification-scenarios.constants.ts b/src/notifications/constants/notification-scenarios.constants.ts index a52882f..027c80a 100644 --- a/src/notifications/constants/notification-scenarios.constants.ts +++ b/src/notifications/constants/notification-scenarios.constants.ts @@ -271,6 +271,34 @@ export const NOTIFICATION_SCENARIOS: NotificationScenarioDefinition[] = [ }, ], }, + { + key: NotificationScenarioKey.DEPOSIT_RECEIPT_SUBMITTED, + label: 'ثبت رسید بیعانه', + description: 'اعلان برای سالن پس از بارگذاری رسید بیعانه توسط مشتری', + defaultTitle: 'رسید بیعانه ثبت شد', + defaultBody: + 'مشتری {customerName} رسید پرداخت بیعانه {depositAmount} تومان را ثبت کرد. برای بررسی وارد شوید.', + variables: [ + { + key: 'customerName', + label: 'نام مشتری', + description: 'نام و نام خانوادگی مشتری', + sampleValue: 'مریم احمدی', + }, + { + key: 'salonName', + label: 'نام سالن', + description: 'نام سالن زیبایی', + sampleValue: 'سالن زیبایی رز', + }, + { + key: 'depositAmount', + label: 'مبلغ بیعانه', + description: 'مبلغ بیعانه به تومان', + sampleValue: '۵۰۰,۰۰۰', + }, + ], + }, ]; export const NOTIFICATION_SCENARIO_MAP = new Map( diff --git a/src/notifications/enums/notification-scenario-key.enum.ts b/src/notifications/enums/notification-scenario-key.enum.ts index b7f8a13..bb6dd96 100644 --- a/src/notifications/enums/notification-scenario-key.enum.ts +++ b/src/notifications/enums/notification-scenario-key.enum.ts @@ -5,4 +5,5 @@ export enum NotificationScenarioKey { RESERVE_REMINDER_SENT = 'reserve_reminder_sent', CAMPAIGN_SENT = 'campaign_sent', CAMPAIGN_FAILED = 'campaign_failed', + DEPOSIT_RECEIPT_SUBMITTED = 'deposit_receipt_submitted', } diff --git a/src/notifications/salon-notifications.service.ts b/src/notifications/salon-notifications.service.ts index 3bcb55e..f0956b5 100644 --- a/src/notifications/salon-notifications.service.ts +++ b/src/notifications/salon-notifications.service.ts @@ -78,6 +78,29 @@ export class SalonNotificationsService { }); } + async notifyDepositReceiptSubmitted(params: { + salonId: string; + salonOwnerId: string; + salonName: string; + customerName: string; + depositAmount: number; + paymentId: string; + }): Promise { + await this.safeCreate({ + salonId: params.salonId, + userId: params.salonOwnerId, + key: NotificationScenarioKey.DEPOSIT_RECEIPT_SUBMITTED, + variables: { + customerName: params.customerName, + salonName: params.salonName, + depositAmount: params.depositAmount.toLocaleString('fa-IR'), + }, + link: `/deposits/${params.paymentId}/review`, + relatedEntityType: 'payment', + relatedEntityId: params.paymentId, + }); + } + async notifyCampaignSent(params: { salon: Salon; type: CampaignType; diff --git a/src/payments/card-to-card-deposit.service.ts b/src/payments/card-to-card-deposit.service.ts new file mode 100644 index 0000000..bfc6b76 --- /dev/null +++ b/src/payments/card-to-card-deposit.service.ts @@ -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, + 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('FRONTEND_URL') ?? 'http://localhost:3000' + ).replace(/\/$/, ''); + } + + async requestDeposit(reserveId: string, user: AuthUser): Promise { + 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 { + 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 { + 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 { + const payment = await this.loadPaymentWithRelations(paymentId); + this.assertCanReview(user, payment); + return payment; + } + + async reviewDeposit( + paymentId: string, + dto: ReviewDepositDto, + user: AuthUser, + ): Promise { + 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 { + 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, + salonId: string, + ): Promise { + 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); + } +} diff --git a/src/payments/deposits.controller.ts b/src/payments/deposits.controller.ts new file mode 100644 index 0000000..135e96b --- /dev/null +++ b/src/payments/deposits.controller.ts @@ -0,0 +1,111 @@ +import { + Body, + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + UploadedFile, + UseGuards, + UseInterceptors, +} from '@nestjs/common'; +import { FileInterceptor } from '@nestjs/platform-express'; +import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; +import { memoryStorage } from 'multer'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Public } from '../auth/decorators/public.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { + ApiStandardController, + ApiStandardOkResponse, +} from '../swagger/decorators/swagger-response.decorator'; +import { ApiPublicEndpoint } from '../swagger/decorators/swagger-auth.decorator'; +import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup'; +import { UserRole } from '../users/enums/user-role.enum'; +import { CardToCardDepositService } from './card-to-card-deposit.service'; +import { PublicDepositPaymentDto } from './dto/public-deposit-payment.dto'; +import { ReviewDepositDto } from './dto/review-deposit.dto'; +import { Payment } from './entities/payment.entity'; + +const MAX_RECEIPT_SIZE_BYTES = 10 * 1024 * 1024; + +@ApiTags('Deposits (Card to Card)') +@ApiStandardController() +@Controller('deposits') +export class DepositsController { + constructor( + private readonly cardToCardDepositService: CardToCardDepositService, + ) {} + + @ApiBearerAuth(SWAGGER_JWT_AUTH) + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SALON) + @ApiStandardOkResponse(Payment) + @Post('reserves/:reserveId') + requestDeposit( + @Param('reserveId', ParseUUIDPipe) reserveId: string, + @CurrentUser() user: AuthUser, + ) { + return this.cardToCardDepositService.requestDeposit(reserveId, user); + } + + @Public() + @ApiPublicEndpoint('Get card-to-card deposit payment details by public token') + @ApiStandardOkResponse(PublicDepositPaymentDto) + @Get('public/:token') + getPublicPayment(@Param('token') token: string) { + return this.cardToCardDepositService.getPublicPaymentByToken(token); + } + + @Public() + @ApiPublicEndpoint('Upload the payment receipt for a card-to-card deposit') + @ApiConsumes('multipart/form-data') + @ApiBody({ + schema: { + type: 'object', + required: ['file'], + properties: { file: { type: 'string', format: 'binary' } }, + }, + }) + @ApiStandardOkResponse(PublicDepositPaymentDto) + @UseInterceptors( + FileInterceptor('file', { + storage: memoryStorage(), + limits: { fileSize: MAX_RECEIPT_SIZE_BYTES }, + }), + ) + @Post('public/:token/receipt') + submitReceipt( + @Param('token') token: string, + @UploadedFile() file: Express.Multer.File, + ) { + return this.cardToCardDepositService.submitReceipt(token, file); + } + + @ApiBearerAuth(SWAGGER_JWT_AUTH) + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SALON) + @ApiStandardOkResponse(Payment) + @Get(':id') + findForReview( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + return this.cardToCardDepositService.findForReview(id, user); + } + + @ApiBearerAuth(SWAGGER_JWT_AUTH) + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SALON) + @ApiStandardOkResponse(Payment) + @Post(':id/review') + review( + @Param('id', ParseUUIDPipe) id: string, + @Body() dto: ReviewDepositDto, + @CurrentUser() user: AuthUser, + ) { + return this.cardToCardDepositService.reviewDeposit(id, dto, user); + } +} diff --git a/src/payments/dto/public-deposit-payment.dto.ts b/src/payments/dto/public-deposit-payment.dto.ts new file mode 100644 index 0000000..6a6296c --- /dev/null +++ b/src/payments/dto/public-deposit-payment.dto.ts @@ -0,0 +1,47 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { PaymentStatus } from '../enums/payment-status.enum'; + +class PublicDepositSalonDto { + @ApiProperty({ example: 'سالن زیبایی رز' }) + name: string; + + @ApiProperty({ example: 'تهران، خیابان ولیعصر' }) + address: string; + + @ApiProperty({ type: [String] }) + images: string[]; +} + +class PublicDepositCardDto { + @ApiProperty({ example: '6037997012345678' }) + cardNumber: string; + + @ApiProperty({ example: 'مریم احمدی' }) + cardHolderName: string; +} + +export class PublicDepositPaymentDto { + @ApiProperty({ example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' }) + paymentId: string; + + @ApiProperty({ enum: PaymentStatus }) + status: PaymentStatus; + + @ApiProperty({ example: 500000 }) + amountToman: number; + + @ApiProperty({ type: PublicDepositSalonDto }) + salon: PublicDepositSalonDto; + + @ApiProperty({ type: PublicDepositCardDto }) + card: PublicDepositCardDto; + + @ApiProperty({ example: '۱۴۰۴/۰۴/۲۰', required: false, nullable: true }) + appointmentDate: string | null; + + @ApiProperty({ example: '۱۰:۰۰', required: false, nullable: true }) + startTime: string | null; + + @ApiProperty({ required: false, nullable: true }) + rejectionReason: string | null; +} diff --git a/src/payments/dto/review-deposit.dto.ts b/src/payments/dto/review-deposit.dto.ts new file mode 100644 index 0000000..2ca3c2b --- /dev/null +++ b/src/payments/dto/review-deposit.dto.ts @@ -0,0 +1,11 @@ +import { IsIn, IsString, Length, ValidateIf } from 'class-validator'; + +export class ReviewDepositDto { + @IsIn(['approve', 'reject']) + decision: 'approve' | 'reject'; + + @ValidateIf((dto: ReviewDepositDto) => dto.decision === 'reject') + @IsString() + @Length(1, 500) + rejectionReason?: string; +} diff --git a/src/payments/entities/payment.entity.ts b/src/payments/entities/payment.entity.ts index 9f3b006..7c0aadc 100644 --- a/src/payments/entities/payment.entity.ts +++ b/src/payments/entities/payment.entity.ts @@ -11,6 +11,8 @@ import { import { Reserve } from '../../reserves/entities/reserve.entity'; import { Salon } from '../../salons/entities/salon.entity'; import { SubscriptionPlan } from '../../subscriptions/entities/subscription-plan.entity'; +import { User } from '../../users/entities/user.entity'; +import { PaymentMethod } from '../enums/payment-method.enum'; import { PaymentStatus } from '../enums/payment-status.enum'; import { PaymentType } from '../enums/payment-type.enum'; @@ -18,6 +20,7 @@ import { PaymentType } from '../enums/payment-type.enum'; @Index(['reserveId']) @Index(['salonId']) @Index(['authority'], { unique: true, where: '"authority" IS NOT NULL' }) +@Index(['publicToken'], { unique: true, where: '"publicToken" IS NOT NULL' }) export class Payment { @PrimaryGeneratedColumn('uuid') id: string; @@ -73,6 +76,36 @@ export class Payment { @Column({ type: 'text' }) description: string; + @Column({ + type: 'enum', + enum: PaymentMethod, + enumName: 'payments_method_enum', + default: PaymentMethod.ZARINPAL, + }) + method: PaymentMethod; + + /** Card-to-card only: opaque token used by the public customer payment page. */ + @Column({ type: 'varchar', length: 64, nullable: true }) + publicToken: string | null; + + /** Card-to-card only: URL of the receipt image/PDF uploaded by the customer. */ + @Column({ type: 'text', nullable: true }) + receiptUrl: string | null; + + /** Card-to-card only: reason provided by the salon when rejecting the receipt. */ + @Column({ type: 'text', nullable: true }) + rejectionReason: string | null; + + @Column({ type: 'timestamp', nullable: true }) + reviewedAt: Date | null; + + @Column({ type: 'uuid', nullable: true }) + reviewedByUserId: string | null; + + @ManyToOne(() => User, { onDelete: 'SET NULL', nullable: true }) + @JoinColumn({ name: 'reviewedByUserId' }) + reviewedByUser: User | null; + @CreateDateColumn() createdAt: Date; diff --git a/src/payments/enums/payment-method.enum.ts b/src/payments/enums/payment-method.enum.ts new file mode 100644 index 0000000..5f5daac --- /dev/null +++ b/src/payments/enums/payment-method.enum.ts @@ -0,0 +1,4 @@ +export enum PaymentMethod { + ZARINPAL = 'zarinpal', + CARD_TO_CARD = 'card_to_card', +} diff --git a/src/payments/enums/payment-status.enum.ts b/src/payments/enums/payment-status.enum.ts index 8327362..7330fcd 100644 --- a/src/payments/enums/payment-status.enum.ts +++ b/src/payments/enums/payment-status.enum.ts @@ -3,4 +3,8 @@ export enum PaymentStatus { PAID = 'paid', FAILED = 'failed', CANCELLED = 'cancelled', + /** Card-to-card only: customer uploaded a receipt, awaiting salon review. */ + IN_REVIEW = 'in_review', + /** Card-to-card only: salon rejected the uploaded receipt. */ + REJECTED = 'rejected', } diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts index 65225d5..afb53b8 100644 --- a/src/payments/payments.module.ts +++ b/src/payments/payments.module.ts @@ -3,9 +3,16 @@ import { Module, forwardRef } from '@nestjs/common'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthGuardsModule } from '../auth/auth-guards.module'; import { AuthorizationModule } from '../authorization/authorization.module'; +import { NotificationsModule } from '../notifications/notifications.module'; import { ReservesModule } from '../reserves/reserves.module'; import { SalonsModule } from '../salons/salons.module'; +import { ShortLinksModule } from '../short-links/short-links.module'; +import { SmsModule } from '../sms/sms.module'; +import { SmsSettingsModule } from '../sms-settings/sms-settings.module'; import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; +import { UploaderModule } from '../uploader/uploader.module'; +import { CardToCardDepositService } from './card-to-card-deposit.service'; +import { DepositsController } from './deposits.controller'; import { Payment } from './entities/payment.entity'; import { PaymentsController } from './payments.controller'; import { PaymentsService } from './payments.service'; @@ -20,9 +27,14 @@ import { ZarinpalService } from './zarinpal.service'; forwardRef(() => SubscriptionsModule), AuthorizationModule, AuthGuardsModule, + SmsSettingsModule, + SmsModule, + ShortLinksModule, + UploaderModule, + NotificationsModule, ], - controllers: [PaymentsController], - providers: [PaymentsService, ZarinpalService], + controllers: [PaymentsController, DepositsController], + providers: [PaymentsService, ZarinpalService, CardToCardDepositService], exports: [PaymentsService], }) export class PaymentsModule {} diff --git a/src/salons/dto/update-salon.dto.ts b/src/salons/dto/update-salon.dto.ts index 012871a..160e641 100644 --- a/src/salons/dto/update-salon.dto.ts +++ b/src/salons/dto/update-salon.dto.ts @@ -1,4 +1,13 @@ -import { IsOptional, IsString, IsUUID, Length, Matches } from 'class-validator'; +import { + ArrayMaxSize, + IsArray, + IsOptional, + IsString, + IsUUID, + IsUrl, + Length, + Matches, +} from 'class-validator'; export class UpdateSalonDto { @IsOptional() @@ -26,4 +35,10 @@ export class UpdateSalonDto { @IsOptional() @IsUUID() cityId?: string; + + @IsOptional() + @IsArray() + @ArrayMaxSize(8) + @IsUrl({}, { each: true }) + images?: string[]; } diff --git a/src/salons/dto/upsert-salon-financial-setting.dto.ts b/src/salons/dto/upsert-salon-financial-setting.dto.ts new file mode 100644 index 0000000..d58bc4c --- /dev/null +++ b/src/salons/dto/upsert-salon-financial-setting.dto.ts @@ -0,0 +1,15 @@ +import { IsNotEmpty, IsString, Length, Matches } from 'class-validator'; + +export class UpsertSalonFinancialSettingDto { + @IsString() + @IsNotEmpty() + @Matches(/^\d{16,19}$/, { + message: 'cardNumber must contain 16 to 19 digits only', + }) + cardNumber: string; + + @IsString() + @IsNotEmpty() + @Length(1, 100) + cardHolderName: string; +} diff --git a/src/salons/entities/salon-financial-setting.entity.ts b/src/salons/entities/salon-financial-setting.entity.ts new file mode 100644 index 0000000..d249d34 --- /dev/null +++ b/src/salons/entities/salon-financial-setting.entity.ts @@ -0,0 +1,37 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + OneToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Salon } from './salon.entity'; + +@Entity('salon_financial_settings') +@Index(['salonId'], { unique: true }) +export class SalonFinancialSetting { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid' }) + salonId: string; + + @OneToOne(() => Salon, { onDelete: 'CASCADE' }) + @JoinColumn({ name: 'salonId' }) + salon: Salon; + + @Column({ length: 19 }) + cardNumber: string; + + @Column({ length: 100 }) + cardHolderName: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/salons/entities/salon.entity.ts b/src/salons/entities/salon.entity.ts index 27bcf70..01510b6 100644 --- a/src/salons/entities/salon.entity.ts +++ b/src/salons/entities/salon.entity.ts @@ -49,6 +49,9 @@ export class Salon { @JoinColumn({ name: 'cityId' }) city: City | null; + @Column({ type: 'jsonb', default: () => "'[]'" }) + images: string[]; + @CreateDateColumn() createdAt: Date; diff --git a/src/salons/salon-financial-settings.controller.ts b/src/salons/salon-financial-settings.controller.ts new file mode 100644 index 0000000..f963d80 --- /dev/null +++ b/src/salons/salon-financial-settings.controller.ts @@ -0,0 +1,52 @@ +import { Body, Controller, Get, Param, ParseUUIDPipe, Put, UseGuards } from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { CurrentUser } from '../auth/decorators/current-user.decorator'; +import { Permissions } from '../auth/decorators/permissions.decorator'; +import { Roles } from '../auth/decorators/roles.decorator'; +import { PermissionsGuard } from '../auth/guards/permissions.guard'; +import { RolesGuard } from '../auth/guards/roles.guard'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { PermissionCode } from '../authorization/enums/permission-code.enum'; +import { + ApiStandardController, + ApiStandardOkResponse, +} from '../swagger/decorators/swagger-response.decorator'; +import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup'; +import { UserRole } from '../users/enums/user-role.enum'; +import { UpsertSalonFinancialSettingDto } from './dto/upsert-salon-financial-setting.dto'; +import { SalonFinancialSetting } from './entities/salon-financial-setting.entity'; +import { SalonFinancialSettingsService } from './salon-financial-settings.service'; + +@ApiTags('Salon Financial Settings') +@ApiBearerAuth(SWAGGER_JWT_AUTH) +@ApiStandardController() +@Controller('salons/:salonId/financial-settings') +@UseGuards(RolesGuard, PermissionsGuard) +export class SalonFinancialSettingsController { + constructor( + private readonly financialSettingsService: SalonFinancialSettingsService, + ) {} + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Permissions(PermissionCode.SALONS_READ) + @ApiStandardOkResponse(SalonFinancialSetting, { nullable: true }) + @Get() + findOne( + @Param('salonId', ParseUUIDPipe) salonId: string, + @CurrentUser() user: AuthUser, + ) { + return this.financialSettingsService.findForOwner(salonId, user); + } + + @Roles(UserRole.ADMIN, UserRole.SALON) + @Permissions(PermissionCode.SALONS_WRITE) + @ApiStandardOkResponse(SalonFinancialSetting) + @Put() + upsert( + @Param('salonId', ParseUUIDPipe) salonId: string, + @Body() dto: UpsertSalonFinancialSettingDto, + @CurrentUser() user: AuthUser, + ) { + return this.financialSettingsService.upsert(salonId, dto, user); + } +} diff --git a/src/salons/salon-financial-settings.service.ts b/src/salons/salon-financial-settings.service.ts new file mode 100644 index 0000000..8494e2a --- /dev/null +++ b/src/salons/salon-financial-settings.service.ts @@ -0,0 +1,66 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, +} from '@nestjs/common'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { SalonPolicy } from '../authorization/policies/salon.policy'; +import { UpsertSalonFinancialSettingDto } from './dto/upsert-salon-financial-setting.dto'; +import { SalonFinancialSetting } from './entities/salon-financial-setting.entity'; +import { SalonsService } from './salons.service'; + +@Injectable() +export class SalonFinancialSettingsService { + constructor( + @InjectRepository(SalonFinancialSetting) + private readonly repository: Repository, + private readonly salonsService: SalonsService, + private readonly salonPolicy: SalonPolicy, + ) {} + + async findForOwner( + salonId: string, + user: AuthUser, + ): Promise { + const salon = await this.salonsService.findOne(salonId); + if (!this.salonPolicy.canRead(user, salon)) { + throw new ForbiddenException(); + } + + return this.repository.findOne({ where: { salonId } }); + } + + async upsert( + salonId: string, + dto: UpsertSalonFinancialSettingDto, + user: AuthUser, + ): Promise { + const salon = await this.salonsService.findOne(salonId); + if (!this.salonPolicy.canUpdate(user, salon)) { + throw new ForbiddenException(); + } + + const existing = await this.repository.findOne({ where: { salonId } }); + const entity = this.repository.create({ + ...existing, + salonId, + cardNumber: dto.cardNumber, + cardHolderName: dto.cardHolderName, + }); + + return this.repository.save(entity); + } + + /** Used internally by the deposit flow; not policy-checked (system access). */ + async findBySalonId(salonId: string): Promise { + const setting = await this.repository.findOne({ where: { salonId } }); + if (!setting) { + throw new BadRequestException( + 'اطلاعات مالی (شماره کارت) سالن ثبت نشده است', + ); + } + return setting; + } +} diff --git a/src/salons/salons.module.ts b/src/salons/salons.module.ts index f612f29..7f78f3e 100644 --- a/src/salons/salons.module.ts +++ b/src/salons/salons.module.ts @@ -4,20 +4,23 @@ import { AuthGuardsModule } from '../auth/auth-guards.module'; import { AuthorizationModule } from '../authorization/authorization.module'; import { ProvincesModule } from '../provinces/provinces.module'; import { UsersModule } from '../users/users.module'; +import { SalonFinancialSetting } from './entities/salon-financial-setting.entity'; import { Salon } from './entities/salon.entity'; +import { SalonFinancialSettingsController } from './salon-financial-settings.controller'; +import { SalonFinancialSettingsService } from './salon-financial-settings.service'; import { SalonsController } from './salons.controller'; import { SalonsService } from './salons.service'; @Module({ imports: [ - TypeOrmModule.forFeature([Salon]), + TypeOrmModule.forFeature([Salon, SalonFinancialSetting]), UsersModule, ProvincesModule, AuthorizationModule, AuthGuardsModule, ], - controllers: [SalonsController], - providers: [SalonsService], - exports: [SalonsService], + controllers: [SalonsController, SalonFinancialSettingsController], + providers: [SalonsService, SalonFinancialSettingsService], + exports: [SalonsService, SalonFinancialSettingsService], }) export class SalonsModule {} diff --git a/src/short-links/entities/short-link.entity.ts b/src/short-links/entities/short-link.entity.ts new file mode 100644 index 0000000..7329efa --- /dev/null +++ b/src/short-links/entities/short-link.entity.ts @@ -0,0 +1,29 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + PrimaryGeneratedColumn, +} from 'typeorm'; + +@Entity('short_links') +export class ShortLink { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ length: 12 }) + @Index({ unique: true }) + code: string; + + @Column({ type: 'text' }) + targetUrl: string; + + @Column({ type: 'int', default: 0 }) + clickCount: number; + + @Column({ type: 'timestamp', nullable: true }) + expiresAt: Date | null; + + @CreateDateColumn() + createdAt: Date; +} diff --git a/src/short-links/short-links.controller.ts b/src/short-links/short-links.controller.ts new file mode 100644 index 0000000..605ce56 --- /dev/null +++ b/src/short-links/short-links.controller.ts @@ -0,0 +1,22 @@ +import { Controller, Get, Param, Res } from '@nestjs/common'; +import { ApiExcludeController } from '@nestjs/swagger'; +import type { Response } from 'express'; +import { Public } from '../auth/decorators/public.decorator'; +import { ShortLinksService } from './short-links.service'; + +/** + * Registered outside the global `api` prefix (see main.ts) so links stay short, + * e.g. `https://api.example.com/s/aB3xQ9k`. + */ +@ApiExcludeController() +@Controller('s') +export class ShortLinksController { + constructor(private readonly shortLinksService: ShortLinksService) {} + + @Public() + @Get(':code') + async redirect(@Param('code') code: string, @Res() res: Response) { + const targetUrl = await this.shortLinksService.resolveAndTrack(code); + return res.redirect(targetUrl); + } +} diff --git a/src/short-links/short-links.module.ts b/src/short-links/short-links.module.ts new file mode 100644 index 0000000..fbdf73c --- /dev/null +++ b/src/short-links/short-links.module.ts @@ -0,0 +1,13 @@ +import { Module } from '@nestjs/common'; +import { TypeOrmModule } from '@nestjs/typeorm'; +import { ShortLink } from './entities/short-link.entity'; +import { ShortLinksController } from './short-links.controller'; +import { ShortLinksService } from './short-links.service'; + +@Module({ + imports: [TypeOrmModule.forFeature([ShortLink])], + controllers: [ShortLinksController], + providers: [ShortLinksService], + exports: [ShortLinksService], +}) +export class ShortLinksModule {} diff --git a/src/short-links/short-links.service.ts b/src/short-links/short-links.service.ts new file mode 100644 index 0000000..6a05dfa --- /dev/null +++ b/src/short-links/short-links.service.ts @@ -0,0 +1,77 @@ +import { GoneException, Injectable, NotFoundException } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { randomBytes } from 'crypto'; +import { Repository } from 'typeorm'; +import { ShortLink } from './entities/short-link.entity'; + +const CODE_ALPHABET = + 'abcdefghijkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789'; +const CODE_LENGTH = 7; +const MAX_GENERATION_ATTEMPTS = 5; + +@Injectable() +export class ShortLinksService { + private readonly apiBaseUrl: string; + + constructor( + @InjectRepository(ShortLink) + private readonly repository: Repository, + private readonly configService: ConfigService, + ) { + this.apiBaseUrl = ( + this.configService.get('API_PUBLIC_URL') ?? + 'http://localhost:4000' + ).replace(/\/$/, ''); + } + + /** Creates a short link and returns its public, shareable URL. */ + async createShortUrl( + targetUrl: string, + expiresAt: Date | null = null, + ): Promise { + const code = await this.generateUniqueCode(); + await this.repository.save( + this.repository.create({ code, targetUrl, expiresAt }), + ); + + return `${this.apiBaseUrl}/s/${code}`; + } + + async resolveAndTrack(code: string): Promise { + const shortLink = await this.repository.findOne({ where: { code } }); + if (!shortLink) { + throw new NotFoundException('لینک یافت نشد'); + } + + if (shortLink.expiresAt && shortLink.expiresAt.getTime() < Date.now()) { + throw new GoneException('این لینک منقضی شده است'); + } + + shortLink.clickCount += 1; + await this.repository.save(shortLink); + + return shortLink.targetUrl; + } + + private async generateUniqueCode(): Promise { + for (let attempt = 0; attempt < MAX_GENERATION_ATTEMPTS; attempt += 1) { + const code = this.generateCode(); + const exists = await this.repository.existsBy({ code }); + if (!exists) { + return code; + } + } + + throw new Error('Failed to generate a unique short link code'); + } + + private generateCode(): string { + const bytes = randomBytes(CODE_LENGTH); + let code = ''; + for (let i = 0; i < CODE_LENGTH; i += 1) { + code += CODE_ALPHABET[bytes[i] % CODE_ALPHABET.length]; + } + return code; + } +} diff --git a/src/sms-settings/constants/sms-scenarios.constants.ts b/src/sms-settings/constants/sms-scenarios.constants.ts index 0a23b23..c550d94 100644 --- a/src/sms-settings/constants/sms-scenarios.constants.ts +++ b/src/sms-settings/constants/sms-scenarios.constants.ts @@ -391,6 +391,135 @@ export const SMS_SCENARIOS: SmsScenarioDefinition[] = [ }, ], }, + { + key: SmsScenarioKey.DEPOSIT_PAYMENT_REQUEST, + label: 'درخواست پرداخت بیعانه (کارت به کارت)', + description: + 'پیامک ارسالی به مشتری برای پرداخت بیعانه به‌صورت کارت به کارت، همراه با لینک صفحه پرداخت', + defaultBody: + '{customerName} عزیز، لطفاً بیعانه {depositAmount} تومان رزرو خود در {salonName} را از طریق لینک زیر پرداخت کنید:\n{paymentLink}', + variables: [ + { + key: 'customerName', + label: 'نام مشتری', + description: 'نام و نام خانوادگی مشتری', + sampleValue: 'مریم احمدی', + }, + { + key: 'salonName', + label: 'نام سالن', + description: 'نام سالن زیبایی', + sampleValue: 'سالن زیبایی رز', + }, + { + key: 'depositAmount', + label: 'مبلغ بیعانه', + description: 'مبلغ بیعانه به تومان', + sampleValue: '۵۰۰,۰۰۰', + }, + { + key: 'paymentLink', + label: 'لینک پرداخت', + description: 'لینک کوتاه صفحه پرداخت بیعانه', + sampleValue: 'https://byb.ir/s/aB3xQ9k', + }, + ], + }, + { + key: SmsScenarioKey.DEPOSIT_RECEIPT_SUBMITTED, + label: 'ثبت رسید بیعانه توسط مشتری', + description: + 'پیامک ارسالی به سالن پس از بارگذاری رسید کارت به کارت توسط مشتری، همراه با لینک بررسی', + defaultBody: + 'مشتری {customerName} رسید پرداخت بیعانه {depositAmount} تومان را ثبت کرد. برای بررسی و تأیید:\n{reviewLink}', + variables: [ + { + key: 'customerName', + label: 'نام مشتری', + description: 'نام و نام خانوادگی مشتری', + sampleValue: 'مریم احمدی', + }, + { + key: 'salonName', + label: 'نام سالن', + description: 'نام سالن زیبایی', + sampleValue: 'سالن زیبایی رز', + }, + { + key: 'depositAmount', + label: 'مبلغ بیعانه', + description: 'مبلغ بیعانه به تومان', + sampleValue: '۵۰۰,۰۰۰', + }, + { + key: 'reviewLink', + label: 'لینک بررسی', + description: 'لینک کوتاه صفحه بررسی رسید در پنل سالن', + sampleValue: 'https://byb.ir/s/kT7mN2p', + }, + ], + }, + { + key: SmsScenarioKey.DEPOSIT_APPROVED, + label: 'تأیید رسید بیعانه', + description: 'پیامک ارسالی به مشتری پس از تأیید رسید کارت به کارت توسط سالن', + defaultBody: + '{customerName} عزیز، رسید پرداخت بیعانه شما تأیید شد و رزرو شما در {salonName} قطعی است.', + variables: [ + { + key: 'customerName', + label: 'نام مشتری', + description: 'نام و نام خانوادگی مشتری', + sampleValue: 'مریم احمدی', + }, + { + key: 'salonName', + label: 'نام سالن', + description: 'نام سالن زیبایی', + sampleValue: 'سالن زیبایی رز', + }, + { + key: 'depositAmount', + label: 'مبلغ بیعانه', + description: 'مبلغ بیعانه به تومان', + sampleValue: '۵۰۰,۰۰۰', + }, + ], + }, + { + key: SmsScenarioKey.DEPOSIT_REJECTED, + label: 'رد رسید بیعانه', + description: + 'پیامک ارسالی به مشتری پس از رد رسید کارت به کارت توسط سالن، همراه با دلیل و لینک پرداخت مجدد', + defaultBody: + '{customerName} عزیز، رسید پرداخت بیعانه شما در {salonName} رد شد.\nدلیل: {rejectionReason}\nبرای پرداخت مجدد:\n{paymentLink}', + variables: [ + { + key: 'customerName', + label: 'نام مشتری', + description: 'نام و نام خانوادگی مشتری', + sampleValue: 'مریم احمدی', + }, + { + key: 'salonName', + label: 'نام سالن', + description: 'نام سالن زیبایی', + sampleValue: 'سالن زیبایی رز', + }, + { + key: 'rejectionReason', + label: 'دلیل رد', + description: 'دلیل رد رسید توسط سالن', + sampleValue: 'کیفیت تصویر رسید نامشخص بود', + }, + { + key: 'paymentLink', + label: 'لینک پرداخت', + description: 'لینک کوتاه صفحه پرداخت بیعانه', + sampleValue: 'https://byb.ir/s/aB3xQ9k', + }, + ], + }, ]; export const SMS_SCENARIO_MAP = new Map( diff --git a/src/sms-settings/enums/sms-scenario-key.enum.ts b/src/sms-settings/enums/sms-scenario-key.enum.ts index c651b58..1775e4d 100644 --- a/src/sms-settings/enums/sms-scenario-key.enum.ts +++ b/src/sms-settings/enums/sms-scenario-key.enum.ts @@ -10,4 +10,8 @@ export enum SmsScenarioKey { CAMPAIGN_VIP = 'campaign_vip', CAMPAIGN_INACTIVE = 'campaign_inactive', CAMPAIGN_COMPLEMENTARY = 'campaign_complementary', + DEPOSIT_PAYMENT_REQUEST = 'deposit_payment_request', + DEPOSIT_RECEIPT_SUBMITTED = 'deposit_receipt_submitted', + DEPOSIT_APPROVED = 'deposit_approved', + DEPOSIT_REJECTED = 'deposit_rejected', } diff --git a/src/sms-settings/utils/build-reserve-sms-variables.util.ts b/src/sms-settings/utils/build-reserve-sms-variables.util.ts index d23438a..6c674a4 100644 --- a/src/sms-settings/utils/build-reserve-sms-variables.util.ts +++ b/src/sms-settings/utils/build-reserve-sms-variables.util.ts @@ -8,8 +8,21 @@ function formatTime(time: string): string { return time.slice(0, 5); } +const PERSIAN_DIGITS = ['۰', '۱', '۲', '۳', '۴', '۵', '۶', '۷', '۸', '۹']; + +function toPersianDigits(value: string): string { + return value.replace(/[0-9]/g, (digit) => PERSIAN_DIGITS[Number(digit)]); +} + +/** + * Formats amounts for SMS bodies using Persian digits grouped with a plain + * ASCII comma. `toLocaleString('fa-IR')` uses the Arabic thousands separator + * (U+066C) which several Iranian SMS gateways can't encode, turning it into + * a "?" on delivery — so we group manually instead. + */ function formatAmount(amount: number): string { - return amount.toLocaleString('fa-IR'); + const grouped = Math.round(amount).toLocaleString('en-US'); + return toPersianDigits(grouped); } function formatDate(date: string): string {