payment
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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: [],
|
||||
|
||||
@@ -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',
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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<void> {
|
||||
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<void> {
|
||||
await queryRunner.dropTable('payments', true);
|
||||
await queryRunner.query(`DROP TYPE "payments_status_enum"`);
|
||||
await queryRunner.query(`DROP TYPE "payments_type_enum"`);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { MigrationInterface, QueryRunner, TableForeignKey, TableIndex } from 'typeorm';
|
||||
|
||||
export class ExtendPaymentsForSubscriptions1720700000000
|
||||
implements MigrationInterface
|
||||
{
|
||||
name = 'ExtendPaymentsForSubscriptions1720700000000';
|
||||
|
||||
public async up(queryRunner: QueryRunner): Promise<void> {
|
||||
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<void> {
|
||||
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
|
||||
`);
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
|
||||
export class PaymentUrlResponseDto {
|
||||
@ApiProperty({
|
||||
example:
|
||||
'https://sandbox.zarinpal.com/pg/StartPay/A00000000000000000000000000217885159',
|
||||
})
|
||||
paymentUrl: string;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum PaymentStatus {
|
||||
PENDING = 'pending',
|
||||
PAID = 'paid',
|
||||
FAILED = 'failed',
|
||||
CANCELLED = 'cancelled',
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum PaymentType {
|
||||
DEPOSIT = 'deposit',
|
||||
REMAINING = 'remaining',
|
||||
SUBSCRIPTION = 'subscription',
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
export interface ZarinpalRequestPayload {
|
||||
merchant_id: string;
|
||||
amount: number;
|
||||
callback_url: string;
|
||||
description: string;
|
||||
metadata?: Record<string, string>;
|
||||
}
|
||||
|
||||
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[];
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<Payment>,
|
||||
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<InitiatePaymentResponseDto> {
|
||||
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<PaymentUrlResponseDto> {
|
||||
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<string> {
|
||||
const returnUrl = this.configService.getOrThrow<string>(
|
||||
'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<Payment[]> {
|
||||
await this.reservesService.findOne(reserveId, user);
|
||||
|
||||
return this.paymentsRepository.find({
|
||||
where: { reserveId },
|
||||
order: { createdAt: 'DESC' },
|
||||
});
|
||||
}
|
||||
|
||||
async findOne(paymentId: string, user: AuthUser): Promise<Payment> {
|
||||
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<string, string>;
|
||||
}): Promise<InitiatePaymentResponseDto> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<string, string | undefined> {
|
||||
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, string | undefined>,
|
||||
): 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();
|
||||
}
|
||||
}
|
||||
@@ -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<string>(
|
||||
'ZARINPAL_MERCHANT_ID',
|
||||
);
|
||||
this.callbackUrl = this.configService.getOrThrow<string>(
|
||||
'ZARINPAL_CALLBACK_URL',
|
||||
);
|
||||
|
||||
const isSandbox =
|
||||
this.configService.get<string>('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<string, string>,
|
||||
): 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<ZarinpalRequestResponse>(
|
||||
`${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<ZarinpalVerifyResponse['data']> {
|
||||
const payload: ZarinpalVerifyPayload = {
|
||||
merchant_id: this.merchantId,
|
||||
amount: amountRial,
|
||||
authority,
|
||||
};
|
||||
|
||||
const response = await this.post<ZarinpalVerifyResponse>(
|
||||
`${this.apiBaseUrl}/verify.json`,
|
||||
payload,
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
private async post<T>(url: string, payload: unknown): Promise<T> {
|
||||
try {
|
||||
const { data } = await firstValueFrom(
|
||||
this.httpService.post<T>(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');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -233,11 +233,38 @@ export class ReservesService {
|
||||
});
|
||||
}
|
||||
|
||||
async confirmDepositPayment(
|
||||
reserveId: string,
|
||||
changedByUserId: string | null,
|
||||
): Promise<void> {
|
||||
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<void> {
|
||||
const entry = this.reserveStatusHistoryRepository.create({
|
||||
reserveId,
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<SalonSubscription> {
|
||||
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<void> {
|
||||
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(
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
Reference in New Issue
Block a user