diff --git a/.env.example b/.env.example index ba23948..e6f4137 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,9 @@ NODE_ENV=development # CORS (comma-separated origins, e.g. https://app.example.com) # CORS_ORIGIN= + +# Zarinpal Payment Gateway +ZARINPAL_MERCHANT_ID=8bf4bd49-df53-44b3-a58d-b068d4c1ec98 +ZARINPAL_SANDBOX=true +ZARINPAL_CALLBACK_URL=http://localhost:4000/api/payments/callback +ZARINPAL_RETURN_URL=http://localhost:3000/payment/result diff --git a/src/app.module.ts b/src/app.module.ts index 9b8b40c..8e48558 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -14,6 +14,7 @@ import { SuggestionsModule } from './suggestions/suggestions.module'; import { StylistsModule } from './stylists/stylists.module'; import { SubscriptionsModule } from './subscriptions/subscriptions.module'; import { UsersModule } from './users/users.module'; +import { PaymentsModule } from './payments/payments.module'; @Module({ imports: [ @@ -33,6 +34,7 @@ import { UsersModule } from './users/users.module'; SmsTemplatesModule, SentMessagesModule, SubscriptionsModule, + PaymentsModule, ], controllers: [], providers: [], diff --git a/src/config/configuration.ts b/src/config/configuration.ts index ce6a71a..c6afd5a 100644 --- a/src/config/configuration.ts +++ b/src/config/configuration.ts @@ -22,5 +22,14 @@ export default () => { OTP_MAX_ATTEMPTS: process.env.OTP_MAX_ATTEMPTS ?? '5', OTP_COOLDOWN_SECONDS: process.env.OTP_COOLDOWN_SECONDS ?? '60', OTP_DEBUG: process.env.OTP_DEBUG ?? 'false', + ZARINPAL_MERCHANT_ID: + process.env.ZARINPAL_MERCHANT_ID ?? + '00000000-0000-0000-0000-000000000001', + ZARINPAL_SANDBOX: process.env.ZARINPAL_SANDBOX ?? 'true', + ZARINPAL_CALLBACK_URL: + process.env.ZARINPAL_CALLBACK_URL ?? + 'http://localhost:4000/api/payments/callback', + ZARINPAL_RETURN_URL: + process.env.ZARINPAL_RETURN_URL ?? 'http://localhost:3000/payment/result', }; }; diff --git a/src/database/migrations/1720600000000-AddPayments.ts b/src/database/migrations/1720600000000-AddPayments.ts new file mode 100644 index 0000000..4ecfa06 --- /dev/null +++ b/src/database/migrations/1720600000000-AddPayments.ts @@ -0,0 +1,99 @@ +import { + MigrationInterface, + QueryRunner, + Table, + TableForeignKey, + TableIndex, +} from 'typeorm'; + +export class AddPayments1720600000000 implements MigrationInterface { + name = 'AddPayments1720600000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + CREATE TYPE "payments_type_enum" AS ENUM('deposit', 'remaining') + `); + + await queryRunner.query(` + CREATE TYPE "payments_status_enum" AS ENUM( + 'pending', + 'paid', + 'failed', + 'cancelled' + ) + `); + + await queryRunner.createTable( + new Table({ + name: 'payments', + columns: [ + { + name: 'id', + type: 'uuid', + isPrimary: true, + generationStrategy: 'uuid', + default: 'gen_random_uuid()', + }, + { name: 'reserveId', type: 'uuid' }, + { + name: 'type', + type: 'payments_type_enum', + }, + { name: 'amountRial', type: 'int' }, + { + name: 'authority', + type: 'varchar', + length: '36', + isNullable: true, + }, + { name: 'refId', type: 'bigint', isNullable: true }, + { + name: 'status', + type: 'payments_status_enum', + default: `'pending'`, + }, + { + name: 'cardPan', + type: 'varchar', + length: '32', + isNullable: true, + }, + { name: 'description', type: 'text' }, + { name: 'createdAt', type: 'timestamp', default: 'now()' }, + { name: 'updatedAt', type: 'timestamp', default: 'now()' }, + ], + }), + true, + ); + + await queryRunner.createForeignKey( + 'payments', + new TableForeignKey({ + columnNames: ['reserveId'], + referencedTableName: 'reserves', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createIndex( + 'payments', + new TableIndex({ + name: 'IDX_payments_reserveId', + columnNames: ['reserveId'], + }), + ); + + await queryRunner.query(` + CREATE UNIQUE INDEX "IDX_payments_authority" + ON "payments" ("authority") + WHERE "authority" IS NOT NULL + `); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropTable('payments', true); + await queryRunner.query(`DROP TYPE "payments_status_enum"`); + await queryRunner.query(`DROP TYPE "payments_type_enum"`); + } +} diff --git a/src/database/migrations/1720700000000-ExtendPaymentsForSubscriptions.ts b/src/database/migrations/1720700000000-ExtendPaymentsForSubscriptions.ts new file mode 100644 index 0000000..6498d91 --- /dev/null +++ b/src/database/migrations/1720700000000-ExtendPaymentsForSubscriptions.ts @@ -0,0 +1,83 @@ +import { MigrationInterface, QueryRunner, TableForeignKey, TableIndex } from 'typeorm'; + +export class ExtendPaymentsForSubscriptions1720700000000 + implements MigrationInterface +{ + name = 'ExtendPaymentsForSubscriptions1720700000000'; + + public async up(queryRunner: QueryRunner): Promise { + await queryRunner.query(` + ALTER TYPE "payments_type_enum" ADD VALUE 'subscription' + `); + + await queryRunner.query(` + ALTER TABLE "payments" ALTER COLUMN "reserveId" DROP NOT NULL + `); + + await queryRunner.query(` + ALTER TABLE "payments" ADD COLUMN "salonId" uuid + `); + + await queryRunner.query(` + ALTER TABLE "payments" ADD COLUMN "planId" uuid + `); + + await queryRunner.createForeignKey( + 'payments', + new TableForeignKey({ + columnNames: ['salonId'], + referencedTableName: 'salons', + referencedColumnNames: ['id'], + onDelete: 'CASCADE', + }), + ); + + await queryRunner.createForeignKey( + 'payments', + new TableForeignKey({ + columnNames: ['planId'], + referencedTableName: 'subscription_plans', + referencedColumnNames: ['id'], + onDelete: 'RESTRICT', + }), + ); + + await queryRunner.createIndex( + 'payments', + new TableIndex({ + name: 'IDX_payments_salonId', + columnNames: ['salonId'], + }), + ); + } + + public async down(queryRunner: QueryRunner): Promise { + await queryRunner.dropIndex('payments', 'IDX_payments_salonId'); + + const table = await queryRunner.getTable('payments'); + const salonFk = table?.foreignKeys.find((fk) => + fk.columnNames.includes('salonId'), + ); + const planFk = table?.foreignKeys.find((fk) => + fk.columnNames.includes('planId'), + ); + + if (salonFk) { + await queryRunner.dropForeignKey('payments', salonFk); + } + if (planFk) { + await queryRunner.dropForeignKey('payments', planFk); + } + + await queryRunner.query(`ALTER TABLE "payments" DROP COLUMN "planId"`); + await queryRunner.query(`ALTER TABLE "payments" DROP COLUMN "salonId"`); + + await queryRunner.query(` + DELETE FROM "payments" WHERE "reserveId" IS NULL + `); + + await queryRunner.query(` + ALTER TABLE "payments" ALTER COLUMN "reserveId" SET NOT NULL + `); + } +} diff --git a/src/payments/dto/initiate-payment-response.dto.ts b/src/payments/dto/initiate-payment-response.dto.ts new file mode 100644 index 0000000..40dce35 --- /dev/null +++ b/src/payments/dto/initiate-payment-response.dto.ts @@ -0,0 +1,15 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class InitiatePaymentResponseDto { + @ApiProperty({ example: 'a1b2c3d4-e5f6-7890-abcd-ef1234567890' }) + paymentId: string; + + @ApiProperty({ + example: + 'https://sandbox.zarinpal.com/pg/StartPay/A00000000000000000000000000217885159', + }) + paymentUrl: string; + + @ApiProperty({ example: 'A00000000000000000000000000217885159' }) + authority: string; +} diff --git a/src/payments/dto/payment-callback-query.dto.ts b/src/payments/dto/payment-callback-query.dto.ts new file mode 100644 index 0000000..41c1a41 --- /dev/null +++ b/src/payments/dto/payment-callback-query.dto.ts @@ -0,0 +1,11 @@ +import { IsIn, IsNotEmpty, IsString } from 'class-validator'; + +export class PaymentCallbackQueryDto { + @IsString() + @IsNotEmpty() + Authority: string; + + @IsString() + @IsIn(['OK', 'NOK']) + Status: 'OK' | 'NOK'; +} diff --git a/src/payments/dto/payment-url-response.dto.ts b/src/payments/dto/payment-url-response.dto.ts new file mode 100644 index 0000000..aa2ec04 --- /dev/null +++ b/src/payments/dto/payment-url-response.dto.ts @@ -0,0 +1,9 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class PaymentUrlResponseDto { + @ApiProperty({ + example: + 'https://sandbox.zarinpal.com/pg/StartPay/A00000000000000000000000000217885159', + }) + paymentUrl: string; +} diff --git a/src/payments/entities/payment.entity.ts b/src/payments/entities/payment.entity.ts new file mode 100644 index 0000000..9f3b006 --- /dev/null +++ b/src/payments/entities/payment.entity.ts @@ -0,0 +1,81 @@ +import { + Column, + CreateDateColumn, + Entity, + Index, + JoinColumn, + ManyToOne, + PrimaryGeneratedColumn, + UpdateDateColumn, +} from 'typeorm'; +import { Reserve } from '../../reserves/entities/reserve.entity'; +import { Salon } from '../../salons/entities/salon.entity'; +import { SubscriptionPlan } from '../../subscriptions/entities/subscription-plan.entity'; +import { PaymentStatus } from '../enums/payment-status.enum'; +import { PaymentType } from '../enums/payment-type.enum'; + +@Entity('payments') +@Index(['reserveId']) +@Index(['salonId']) +@Index(['authority'], { unique: true, where: '"authority" IS NOT NULL' }) +export class Payment { + @PrimaryGeneratedColumn('uuid') + id: string; + + @Column({ type: 'uuid', nullable: true }) + reserveId: string | null; + + @ManyToOne(() => Reserve, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'reserveId' }) + reserve: Reserve | null; + + @Column({ type: 'uuid', nullable: true }) + salonId: string | null; + + @ManyToOne(() => Salon, { onDelete: 'CASCADE', nullable: true }) + @JoinColumn({ name: 'salonId' }) + salon: Salon | null; + + @Column({ type: 'uuid', nullable: true }) + planId: string | null; + + @ManyToOne(() => SubscriptionPlan, { onDelete: 'RESTRICT', nullable: true }) + @JoinColumn({ name: 'planId' }) + plan: SubscriptionPlan | null; + + @Column({ + type: 'enum', + enum: PaymentType, + enumName: 'payments_type_enum', + }) + type: PaymentType; + + @Column({ type: 'int' }) + amountRial: number; + + @Column({ type: 'varchar', length: 36, nullable: true }) + authority: string | null; + + @Column({ type: 'bigint', nullable: true }) + refId: string | null; + + @Column({ + type: 'enum', + enum: PaymentStatus, + enumName: 'payments_status_enum', + default: PaymentStatus.PENDING, + }) + status: PaymentStatus; + + @Column({ type: 'varchar', length: 32, nullable: true }) + cardPan: string | null; + + @Column({ type: 'text' }) + description: string; + + @CreateDateColumn() + createdAt: Date; + + @UpdateDateColumn() + updatedAt: Date; +} diff --git a/src/payments/enums/payment-status.enum.ts b/src/payments/enums/payment-status.enum.ts new file mode 100644 index 0000000..8327362 --- /dev/null +++ b/src/payments/enums/payment-status.enum.ts @@ -0,0 +1,6 @@ +export enum PaymentStatus { + PENDING = 'pending', + PAID = 'paid', + FAILED = 'failed', + CANCELLED = 'cancelled', +} diff --git a/src/payments/enums/payment-type.enum.ts b/src/payments/enums/payment-type.enum.ts new file mode 100644 index 0000000..9c48c97 --- /dev/null +++ b/src/payments/enums/payment-type.enum.ts @@ -0,0 +1,5 @@ +export enum PaymentType { + DEPOSIT = 'deposit', + REMAINING = 'remaining', + SUBSCRIPTION = 'subscription', +} diff --git a/src/payments/interfaces/zarinpal.interface.ts b/src/payments/interfaces/zarinpal.interface.ts new file mode 100644 index 0000000..e5d6ea0 --- /dev/null +++ b/src/payments/interfaces/zarinpal.interface.ts @@ -0,0 +1,43 @@ +export interface ZarinpalRequestPayload { + merchant_id: string; + amount: number; + callback_url: string; + description: string; + metadata?: Record; +} + +export interface ZarinpalRequestResponse { + data: { + code: number; + message: string; + authority: string; + fee_type: string; + fee: number; + }; + errors: ZarinpalError[]; +} + +export interface ZarinpalVerifyPayload { + merchant_id: string; + amount: number; + authority: string; +} + +export interface ZarinpalVerifyResponse { + data: { + code: number; + message: string; + card_hash: string; + card_pan: string; + ref_id: number; + fee_type: string; + fee: number; + }; + errors: ZarinpalError[]; +} + +export interface ZarinpalError { + code: number; + message: string; + validations?: unknown[]; +} diff --git a/src/payments/payments.controller.ts b/src/payments/payments.controller.ts new file mode 100644 index 0000000..8a0ae9d --- /dev/null +++ b/src/payments/payments.controller.ts @@ -0,0 +1,84 @@ +import { + Controller, + Get, + Param, + ParseUUIDPipe, + Post, + Query, + Res, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import type { Response } from 'express'; +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 { UserRole } from '../users/enums/user-role.enum'; +import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup'; +import { + ApiStandardController, + ApiStandardOkResponse, +} from '../swagger/decorators/swagger-response.decorator'; +import { InitiatePaymentResponseDto } from './dto/initiate-payment-response.dto'; +import { PaymentCallbackQueryDto } from './dto/payment-callback-query.dto'; +import { Payment } from './entities/payment.entity'; +import { PaymentsService } from './payments.service'; + +@ApiTags('Payments') +@ApiStandardController() +@Controller('payments') +export class PaymentsController { + constructor(private readonly paymentsService: PaymentsService) {} + + @Public() + @Get('callback') + async handleCallback( + @Query() query: PaymentCallbackQueryDto, + @Res() res: Response, + ) { + const redirectUrl = await this.paymentsService.handleCallback( + query.Authority, + query.Status, + ); + + return res.redirect(redirectUrl); + } + + @ApiBearerAuth(SWAGGER_JWT_AUTH) + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SALON, UserRole.CUSTOMER) + @ApiStandardOkResponse(InitiatePaymentResponseDto) + @Post('reserves/:reserveId/deposit') + initiateDepositPayment( + @Param('reserveId', ParseUUIDPipe) reserveId: string, + @CurrentUser() user: AuthUser, + ) { + return this.paymentsService.initiateDepositPayment(reserveId, user); + } + + @ApiBearerAuth(SWAGGER_JWT_AUTH) + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SALON, UserRole.CUSTOMER) + @ApiStandardOkResponse(Payment, { isArray: true }) + @Get('reserves/:reserveId') + findByReserve( + @Param('reserveId', ParseUUIDPipe) reserveId: string, + @CurrentUser() user: AuthUser, + ) { + return this.paymentsService.findByReserveId(reserveId, user); + } + + @ApiBearerAuth(SWAGGER_JWT_AUTH) + @UseGuards(RolesGuard) + @Roles(UserRole.ADMIN, UserRole.SALON, UserRole.CUSTOMER) + @ApiStandardOkResponse(Payment) + @Get(':id') + findOne( + @Param('id', ParseUUIDPipe) id: string, + @CurrentUser() user: AuthUser, + ) { + return this.paymentsService.findOne(id, user); + } +} diff --git a/src/payments/payments.module.ts b/src/payments/payments.module.ts new file mode 100644 index 0000000..65225d5 --- /dev/null +++ b/src/payments/payments.module.ts @@ -0,0 +1,28 @@ +import { HttpModule } from '@nestjs/axios'; +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 { ReservesModule } from '../reserves/reserves.module'; +import { SalonsModule } from '../salons/salons.module'; +import { SubscriptionsModule } from '../subscriptions/subscriptions.module'; +import { Payment } from './entities/payment.entity'; +import { PaymentsController } from './payments.controller'; +import { PaymentsService } from './payments.service'; +import { ZarinpalService } from './zarinpal.service'; + +@Module({ + imports: [ + HttpModule, + TypeOrmModule.forFeature([Payment]), + ReservesModule, + SalonsModule, + forwardRef(() => SubscriptionsModule), + AuthorizationModule, + AuthGuardsModule, + ], + controllers: [PaymentsController], + providers: [PaymentsService, ZarinpalService], + exports: [PaymentsService], +}) +export class PaymentsModule {} diff --git a/src/payments/payments.service.ts b/src/payments/payments.service.ts new file mode 100644 index 0000000..40b1694 --- /dev/null +++ b/src/payments/payments.service.ts @@ -0,0 +1,371 @@ +import { + BadRequestException, + ForbiddenException, + forwardRef, + Inject, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { InjectRepository } from '@nestjs/typeorm'; +import { Repository } from 'typeorm'; +import { AuthUser } from '../auth/interfaces/auth-user.interface'; +import { ReservePolicy } from '../authorization/policies/reserve.policy'; +import { SubscriptionPolicy } from '../authorization/policies/subscription.policy'; +import { ReserveStatus } from '../reserves/enums/reserve-status.enum'; +import { ReservesService } from '../reserves/reserves.service'; +import { SalonsService } from '../salons/salons.service'; +import { PurchaseSubscriptionDto } from '../subscriptions/dto/purchase-subscription.dto'; +import { SalonSubscriptionsService } from '../subscriptions/salon-subscriptions.service'; +import { SubscriptionPlansService } from '../subscriptions/subscription-plans.service'; +import { InitiatePaymentResponseDto } from './dto/initiate-payment-response.dto'; +import { PaymentUrlResponseDto } from './dto/payment-url-response.dto'; +import { Payment } from './entities/payment.entity'; +import { PaymentStatus } from './enums/payment-status.enum'; +import { PaymentType } from './enums/payment-type.enum'; +import { ZarinpalService } from './zarinpal.service'; + +const MIN_AMOUNT_RIAL = 1000; + +@Injectable() +export class PaymentsService { + constructor( + @InjectRepository(Payment) + private readonly paymentsRepository: Repository, + private readonly zarinpalService: ZarinpalService, + private readonly reservesService: ReservesService, + private readonly reservePolicy: ReservePolicy, + private readonly subscriptionPolicy: SubscriptionPolicy, + private readonly salonsService: SalonsService, + private readonly subscriptionPlansService: SubscriptionPlansService, + @Inject(forwardRef(() => SalonSubscriptionsService)) + private readonly salonSubscriptionsService: SalonSubscriptionsService, + private readonly configService: ConfigService, + ) {} + + async initiateDepositPayment( + reserveId: string, + user: AuthUser, + ): Promise { + 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'); + } + + const existingPaid = await this.paymentsRepository.existsBy({ + reserveId, + type: PaymentType.DEPOSIT, + status: PaymentStatus.PAID, + }); + + if (existingPaid) { + throw new BadRequestException('Deposit has already been paid'); + } + + const amountRial = this.tomanToRial(reserve.depositAmount); + this.validateAmountRial(amountRial); + + const description = `پرداخت بیعانه رزرو ${reserve.id}`; + + return this.createPayment({ + type: PaymentType.DEPOSIT, + amountRial, + description, + reserveId: reserve.id, + metadata: { reserve_id: reserve.id }, + }); + } + + async initiateSubscriptionPayment( + dto: PurchaseSubscriptionDto, + user: AuthUser, + ): Promise { + await this.salonSubscriptionsService.validateSubscriptionPurchase( + dto.salonId, + dto.planId, + user, + ); + + const existingPaid = await this.paymentsRepository.existsBy({ + salonId: dto.salonId, + type: PaymentType.SUBSCRIPTION, + status: PaymentStatus.PAID, + }); + + if (existingPaid) { + throw new BadRequestException('Subscription has already been paid'); + } + + const plan = await this.subscriptionPlansService.findActiveById(dto.planId); + const amountRial = this.tomanToRial(Number(plan.price)); + this.validateAmountRial(amountRial); + + const description = `خرید اشتراک ${plan.name}`; + + const payment = await this.createPayment({ + type: PaymentType.SUBSCRIPTION, + amountRial, + description, + salonId: dto.salonId, + planId: dto.planId, + metadata: { + salon_id: dto.salonId, + plan_id: dto.planId, + }, + }); + + return { paymentUrl: payment.paymentUrl }; + } + + async handleCallback( + authority: string, + status: 'OK' | 'NOK', + ): Promise { + const returnUrl = this.configService.getOrThrow( + 'ZARINPAL_RETURN_URL', + ); + + const payment = await this.paymentsRepository.findOne({ + where: { authority }, + relations: { + reserve: { customer: true }, + salon: true, + plan: true, + }, + }); + + if (!payment) { + return this.buildReturnUrl(returnUrl, { + success: 'false', + reason: 'payment_not_found', + }); + } + + if (payment.status === PaymentStatus.PAID) { + return this.buildSuccessReturnUrl(returnUrl, payment); + } + + if (status !== 'OK') { + payment.status = PaymentStatus.CANCELLED; + await this.paymentsRepository.save(payment); + + return this.buildReturnUrl(returnUrl, { + success: 'false', + type: payment.type, + reason: 'cancelled', + ...this.getPaymentContextParams(payment), + }); + } + + const verifyResult = await this.zarinpalService.verifyPayment( + payment.amountRial, + authority, + ); + + if (verifyResult.code === 100 || verifyResult.code === 101) { + payment.status = PaymentStatus.PAID; + if (!payment.refId) { + payment.refId = String(verifyResult.ref_id); + } + if (!payment.cardPan) { + payment.cardPan = verifyResult.card_pan; + } + await this.paymentsRepository.save(payment); + + if (verifyResult.code === 100) { + await this.fulfillPayment(payment); + } + + return this.buildSuccessReturnUrl(returnUrl, payment); + } + + payment.status = PaymentStatus.FAILED; + await this.paymentsRepository.save(payment); + + return this.buildReturnUrl(returnUrl, { + success: 'false', + type: payment.type, + reason: 'verification_failed', + code: String(verifyResult.code), + ...this.getPaymentContextParams(payment), + }); + } + + async findByReserveId( + reserveId: string, + user: AuthUser, + ): Promise { + await this.reservesService.findOne(reserveId, user); + + return this.paymentsRepository.find({ + where: { reserveId }, + order: { createdAt: 'DESC' }, + }); + } + + async findOne(paymentId: string, user: AuthUser): Promise { + const payment = await this.paymentsRepository.findOne({ + where: { id: paymentId }, + relations: { + reserve: { salon: true, customer: { user: true } }, + salon: true, + plan: true, + }, + }); + + if (!payment) { + throw new NotFoundException(`Payment #${paymentId} not found`); + } + + await this.assertCanReadPayment(user, payment); + return payment; + } + + private async createPayment(params: { + type: PaymentType; + amountRial: number; + description: string; + reserveId?: string; + salonId?: string; + planId?: string; + metadata?: Record; + }): Promise { + const { authority, paymentUrl } = await this.zarinpalService.requestPayment( + params.amountRial, + params.description, + params.metadata, + ); + + const payment = this.paymentsRepository.create({ + type: params.type, + amountRial: params.amountRial, + authority, + status: PaymentStatus.PENDING, + description: params.description, + reserveId: params.reserveId ?? null, + salonId: params.salonId ?? null, + planId: params.planId ?? null, + }); + + const saved = await this.paymentsRepository.save(payment); + + return { + paymentId: saved.id, + paymentUrl, + authority, + }; + } + + private async fulfillPayment(payment: Payment): Promise { + if (payment.type === PaymentType.DEPOSIT && payment.reserveId) { + const reserve = payment.reserve; + const changedByUserId = reserve?.customer?.userId ?? null; + await this.reservesService.confirmDepositPayment( + payment.reserveId, + changedByUserId, + ); + return; + } + + if ( + payment.type === PaymentType.SUBSCRIPTION && + payment.salonId && + payment.planId + ) { + await this.salonSubscriptionsService.fulfillSubscriptionPurchase( + payment.salonId, + payment.planId, + ); + } + } + + private async assertCanReadPayment( + user: AuthUser, + payment: Payment, + ): Promise { + if (payment.reserveId && payment.reserve) { + if (!this.reservePolicy.canRead(user, payment.reserve)) { + throw new ForbiddenException(); + } + return; + } + + if (payment.salonId) { + const salon = + payment.salon ?? (await this.salonsService.findOne(payment.salonId)); + if (!this.subscriptionPolicy.canRead(user, salon)) { + throw new ForbiddenException(); + } + return; + } + + throw new ForbiddenException(); + } + + private buildSuccessReturnUrl( + returnUrl: string, + payment: Payment, + ): string { + return this.buildReturnUrl(returnUrl, { + success: 'true', + type: payment.type, + refId: payment.refId ?? undefined, + ...this.getPaymentContextParams(payment), + }); + } + + private getPaymentContextParams( + payment: Payment, + ): Record { + if (payment.type === PaymentType.DEPOSIT) { + return { reserveId: payment.reserveId ?? undefined }; + } + + if (payment.type === PaymentType.SUBSCRIPTION) { + return { + salonId: payment.salonId ?? undefined, + planId: payment.planId ?? undefined, + }; + } + + return {}; + } + + private validateAmountRial(amountRial: number): void { + if (amountRial < MIN_AMOUNT_RIAL) { + throw new BadRequestException( + `Minimum payment amount is ${MIN_AMOUNT_RIAL} Rials`, + ); + } + } + + private tomanToRial(amountToman: number): number { + return Math.round(amountToman * 10); + } + + private buildReturnUrl( + baseUrl: string, + params: Record, + ): string { + const url = new URL(baseUrl); + + for (const [key, value] of Object.entries(params)) { + if (value !== undefined) { + url.searchParams.set(key, value); + } + } + + return url.toString(); + } +} diff --git a/src/payments/zarinpal.service.ts b/src/payments/zarinpal.service.ts new file mode 100644 index 0000000..be8b467 --- /dev/null +++ b/src/payments/zarinpal.service.ts @@ -0,0 +1,114 @@ +import { HttpService } from '@nestjs/axios'; +import { + BadRequestException, + Injectable, + InternalServerErrorException, + Logger, +} from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { firstValueFrom } from 'rxjs'; +import { + ZarinpalRequestPayload, + ZarinpalRequestResponse, + ZarinpalVerifyPayload, + ZarinpalVerifyResponse, +} from './interfaces/zarinpal.interface'; + +@Injectable() +export class ZarinpalService { + private readonly logger = new Logger(ZarinpalService.name); + private readonly merchantId: string; + private readonly callbackUrl: string; + private readonly apiBaseUrl: string; + private readonly gatewayBaseUrl: string; + + constructor( + private readonly httpService: HttpService, + private readonly configService: ConfigService, + ) { + this.merchantId = this.configService.getOrThrow( + 'ZARINPAL_MERCHANT_ID', + ); + this.callbackUrl = this.configService.getOrThrow( + 'ZARINPAL_CALLBACK_URL', + ); + + const isSandbox = + this.configService.get('ZARINPAL_SANDBOX', 'true') === 'true'; + + this.apiBaseUrl = isSandbox + ? 'https://sandbox.zarinpal.com/pg/v4/payment' + : 'https://api.zarinpal.com/pg/v4/payment'; + this.gatewayBaseUrl = isSandbox + ? 'https://sandbox.zarinpal.com/pg/StartPay' + : 'https://www.zarinpal.com/pg/StartPay'; + } + + async requestPayment( + amountRial: number, + description: string, + metadata?: Record, + ): Promise<{ authority: string; paymentUrl: string }> { + const payload: ZarinpalRequestPayload = { + merchant_id: this.merchantId, + amount: amountRial, + callback_url: this.callbackUrl, + description, + ...(metadata ? { metadata } : {}), + }; + + const response = await this.post( + `${this.apiBaseUrl}/request.json`, + payload, + ); + + if (response.data.code !== 100 || !response.data.authority) { + this.logger.error( + `Zarinpal request failed: code=${response.data.code}, message=${response.data.message}`, + ); + throw new BadRequestException('Payment gateway request failed'); + } + + const authority = response.data.authority; + + return { + authority, + paymentUrl: `${this.gatewayBaseUrl}/${authority}`, + }; + } + + async verifyPayment( + amountRial: number, + authority: string, + ): Promise { + const payload: ZarinpalVerifyPayload = { + merchant_id: this.merchantId, + amount: amountRial, + authority, + }; + + const response = await this.post( + `${this.apiBaseUrl}/verify.json`, + payload, + ); + + return response.data; + } + + private async post(url: string, payload: unknown): Promise { + try { + const { data } = await firstValueFrom( + this.httpService.post(url, payload, { + headers: { + accept: 'application/json', + 'content-type': 'application/json', + }, + }), + ); + return data; + } catch (error) { + this.logger.error(`Zarinpal API call failed: ${url}`, error); + throw new InternalServerErrorException('Payment gateway is unavailable'); + } + } +} diff --git a/src/reserves/reserves.service.ts b/src/reserves/reserves.service.ts index 28e7a0e..27928ef 100644 --- a/src/reserves/reserves.service.ts +++ b/src/reserves/reserves.service.ts @@ -233,11 +233,38 @@ export class ReservesService { }); } + async confirmDepositPayment( + reserveId: string, + changedByUserId: string | null, + ): Promise { + const reserve = await this.reservesRepository.findOne({ + where: { id: reserveId }, + }); + + if (!reserve) { + throw new NotFoundException(`Reserve #${reserveId} not found`); + } + + if (reserve.status !== ReserveStatus.PENDING) { + return; + } + + await this.logStatusChange( + reserve.id, + reserve.status, + ReserveStatus.CONFIRMED, + changedByUserId, + ); + + reserve.status = ReserveStatus.CONFIRMED; + await this.reservesRepository.save(reserve); + } + private async logStatusChange( reserveId: string, fromStatus: ReserveStatus | null, toStatus: ReserveStatus, - changedByUserId: string, + changedByUserId: string | null, ): Promise { const entry = this.reserveStatusHistoryRepository.create({ reserveId, diff --git a/src/subscriptions/salon-subscriptions.controller.ts b/src/subscriptions/salon-subscriptions.controller.ts index a8f93ab..be730dc 100644 --- a/src/subscriptions/salon-subscriptions.controller.ts +++ b/src/subscriptions/salon-subscriptions.controller.ts @@ -20,6 +20,8 @@ import { AllowWithoutSubscription } from '../auth/decorators/allow-without-subsc import { PurchaseSmsPackageDto } from './dto/purchase-sms-package.dto'; import { PurchaseSubscriptionDto } from './dto/purchase-subscription.dto'; import { SalonSubscriptionsService } from './salon-subscriptions.service'; +import { PaymentsService } from '../payments/payments.service'; +import { PaymentUrlResponseDto } from '../payments/dto/payment-url-response.dto'; import { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup'; import { ApiStandardController, @@ -37,18 +39,19 @@ import { SalonSubscription } from './entities/salon-subscription.entity'; export class SalonSubscriptionsController { constructor( private readonly salonSubscriptionsService: SalonSubscriptionsService, + private readonly paymentsService: PaymentsService, ) {} @Roles(UserRole.ADMIN, UserRole.SALON) @Permissions(PermissionCode.SUBSCRIPTIONS_WRITE) @AllowWithoutSubscription() - @ApiStandardOkResponse(SalonSubscription) + @ApiStandardOkResponse(PaymentUrlResponseDto) @Post('purchase') purchaseSubscription( @Body() dto: PurchaseSubscriptionDto, @CurrentUser() user: AuthUser, ) { - return this.salonSubscriptionsService.purchaseSubscription(dto, user); + return this.paymentsService.initiateSubscriptionPayment(dto, user); } @Roles(UserRole.ADMIN, UserRole.SALON) diff --git a/src/subscriptions/salon-subscriptions.service.ts b/src/subscriptions/salon-subscriptions.service.ts index 1d3dbfb..417a14d 100644 --- a/src/subscriptions/salon-subscriptions.service.ts +++ b/src/subscriptions/salon-subscriptions.service.ts @@ -11,7 +11,6 @@ import { SubscriptionPolicy } from '../authorization/policies/subscription.polic import { SalonsService } from '../salons/salons.service'; import { SMS_ROLLOVER_GRACE_DAYS } from './constants/subscription.constants'; import { PurchaseSmsPackageDto } from './dto/purchase-sms-package.dto'; -import { PurchaseSubscriptionDto } from './dto/purchase-subscription.dto'; import { SalonSmsPurchase } from './entities/salon-sms-purchase.entity'; import { SalonSubscription } from './entities/salon-subscription.entity'; import { SalonSubscriptionStatus } from './enums/salon-subscription-status.enum'; @@ -32,27 +31,22 @@ export class SalonSubscriptionsService { private readonly subscriptionPolicy: SubscriptionPolicy, ) {} - async purchaseSubscription( - dto: PurchaseSubscriptionDto, - user: AuthUser, + async fulfillSubscriptionPurchase( + salonId: string, + planId: string, ): Promise { - const salon = await this.salonsService.findOne(dto.salonId); - if (!this.subscriptionPolicy.canPurchase(user, salon)) { - throw new ForbiddenException(); - } - - const plan = await this.subscriptionPlansService.findActiveById(dto.planId); - const activeSubscription = await this.findActiveSubscription(dto.salonId); + const activeSubscription = await this.findActiveSubscription(salonId); if (activeSubscription) { throw new BadRequestException('Salon already has an active subscription'); } + const plan = await this.subscriptionPlansService.findActiveById(planId); const now = new Date(); - const rolloverSms = await this.calculateSmsRollover(dto.salonId, now); + const rolloverSms = await this.calculateSmsRollover(salonId, now); const freeSmsGranted = plan.freeSmsCount + rolloverSms; const subscription = this.subscriptionsRepository.create({ - salonId: dto.salonId, + salonId, planId: plan.id, status: SalonSubscriptionStatus.ACTIVE, startDate: now, @@ -63,7 +57,28 @@ export class SalonSubscriptionsService { }); const saved = await this.subscriptionsRepository.save(subscription); - return this.findOne(saved.id, user); + return this.subscriptionsRepository.findOneOrFail({ + where: { id: saved.id }, + relations: { plan: true, salon: true }, + }); + } + + async validateSubscriptionPurchase( + salonId: string, + planId: string, + user: AuthUser, + ): Promise { + const salon = await this.salonsService.findOne(salonId); + if (!this.subscriptionPolicy.canPurchase(user, salon)) { + throw new ForbiddenException(); + } + + await this.subscriptionPlansService.findActiveById(planId); + + const activeSubscription = await this.findActiveSubscription(salonId); + if (activeSubscription) { + throw new BadRequestException('Salon already has an active subscription'); + } } async purchaseSmsPackage( diff --git a/src/subscriptions/subscriptions.module.ts b/src/subscriptions/subscriptions.module.ts index 0cb3fee..7b84d25 100644 --- a/src/subscriptions/subscriptions.module.ts +++ b/src/subscriptions/subscriptions.module.ts @@ -1,8 +1,9 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { APP_GUARD } from '@nestjs/core'; import { TypeOrmModule } from '@nestjs/typeorm'; import { AuthGuardsModule } from '../auth/auth-guards.module'; import { AuthorizationModule } from '../authorization/authorization.module'; +import { PaymentsModule } from '../payments/payments.module'; import { SalonsModule } from '../salons/salons.module'; import { SalonSmsPurchase } from './entities/salon-sms-purchase.entity'; import { SalonSubscription } from './entities/salon-subscription.entity'; @@ -27,6 +28,7 @@ import { SubscriptionPlansService } from './subscription-plans.service'; SalonsModule, AuthorizationModule, AuthGuardsModule, + forwardRef(() => PaymentsModule), ], controllers: [ SubscriptionPlansController, @@ -42,6 +44,6 @@ import { SubscriptionPlansService } from './subscription-plans.service'; useClass: ActiveSubscriptionGuard, }, ], - exports: [SalonSubscriptionsService], + exports: [SalonSubscriptionsService, SubscriptionPlansService], }) export class SubscriptionsModule {}