This commit is contained in:
2026-07-03 17:18:45 +03:30
parent 5042cadb32
commit 74d5b7a768
20 changed files with 1032 additions and 19 deletions
+6
View File
@@ -32,3 +32,9 @@ NODE_ENV=development
# CORS (comma-separated origins, e.g. https://app.example.com) # CORS (comma-separated origins, e.g. https://app.example.com)
# CORS_ORIGIN= # 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
+2
View File
@@ -14,6 +14,7 @@ import { SuggestionsModule } from './suggestions/suggestions.module';
import { StylistsModule } from './stylists/stylists.module'; import { StylistsModule } from './stylists/stylists.module';
import { SubscriptionsModule } from './subscriptions/subscriptions.module'; import { SubscriptionsModule } from './subscriptions/subscriptions.module';
import { UsersModule } from './users/users.module'; import { UsersModule } from './users/users.module';
import { PaymentsModule } from './payments/payments.module';
@Module({ @Module({
imports: [ imports: [
@@ -33,6 +34,7 @@ import { UsersModule } from './users/users.module';
SmsTemplatesModule, SmsTemplatesModule,
SentMessagesModule, SentMessagesModule,
SubscriptionsModule, SubscriptionsModule,
PaymentsModule,
], ],
controllers: [], controllers: [],
providers: [], providers: [],
+9
View File
@@ -22,5 +22,14 @@ export default () => {
OTP_MAX_ATTEMPTS: process.env.OTP_MAX_ATTEMPTS ?? '5', OTP_MAX_ATTEMPTS: process.env.OTP_MAX_ATTEMPTS ?? '5',
OTP_COOLDOWN_SECONDS: process.env.OTP_COOLDOWN_SECONDS ?? '60', OTP_COOLDOWN_SECONDS: process.env.OTP_COOLDOWN_SECONDS ?? '60',
OTP_DEBUG: process.env.OTP_DEBUG ?? 'false', 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;
}
+81
View File
@@ -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',
}
+5
View File
@@ -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[];
}
+84
View File
@@ -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);
}
}
+28
View File
@@ -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 {}
+371
View File
@@ -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();
}
}
+114
View File
@@ -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');
}
}
}
+28 -1
View File
@@ -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( private async logStatusChange(
reserveId: string, reserveId: string,
fromStatus: ReserveStatus | null, fromStatus: ReserveStatus | null,
toStatus: ReserveStatus, toStatus: ReserveStatus,
changedByUserId: string, changedByUserId: string | null,
): Promise<void> { ): Promise<void> {
const entry = this.reserveStatusHistoryRepository.create({ const entry = this.reserveStatusHistoryRepository.create({
reserveId, reserveId,
@@ -20,6 +20,8 @@ import { AllowWithoutSubscription } from '../auth/decorators/allow-without-subsc
import { PurchaseSmsPackageDto } from './dto/purchase-sms-package.dto'; import { PurchaseSmsPackageDto } from './dto/purchase-sms-package.dto';
import { PurchaseSubscriptionDto } from './dto/purchase-subscription.dto'; import { PurchaseSubscriptionDto } from './dto/purchase-subscription.dto';
import { SalonSubscriptionsService } from './salon-subscriptions.service'; 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 { SWAGGER_JWT_AUTH } from '../swagger/swagger.setup';
import { import {
ApiStandardController, ApiStandardController,
@@ -37,18 +39,19 @@ import { SalonSubscription } from './entities/salon-subscription.entity';
export class SalonSubscriptionsController { export class SalonSubscriptionsController {
constructor( constructor(
private readonly salonSubscriptionsService: SalonSubscriptionsService, private readonly salonSubscriptionsService: SalonSubscriptionsService,
private readonly paymentsService: PaymentsService,
) {} ) {}
@Roles(UserRole.ADMIN, UserRole.SALON) @Roles(UserRole.ADMIN, UserRole.SALON)
@Permissions(PermissionCode.SUBSCRIPTIONS_WRITE) @Permissions(PermissionCode.SUBSCRIPTIONS_WRITE)
@AllowWithoutSubscription() @AllowWithoutSubscription()
@ApiStandardOkResponse(SalonSubscription) @ApiStandardOkResponse(PaymentUrlResponseDto)
@Post('purchase') @Post('purchase')
purchaseSubscription( purchaseSubscription(
@Body() dto: PurchaseSubscriptionDto, @Body() dto: PurchaseSubscriptionDto,
@CurrentUser() user: AuthUser, @CurrentUser() user: AuthUser,
) { ) {
return this.salonSubscriptionsService.purchaseSubscription(dto, user); return this.paymentsService.initiateSubscriptionPayment(dto, user);
} }
@Roles(UserRole.ADMIN, UserRole.SALON) @Roles(UserRole.ADMIN, UserRole.SALON)
@@ -11,7 +11,6 @@ import { SubscriptionPolicy } from '../authorization/policies/subscription.polic
import { SalonsService } from '../salons/salons.service'; import { SalonsService } from '../salons/salons.service';
import { SMS_ROLLOVER_GRACE_DAYS } from './constants/subscription.constants'; import { SMS_ROLLOVER_GRACE_DAYS } from './constants/subscription.constants';
import { PurchaseSmsPackageDto } from './dto/purchase-sms-package.dto'; import { PurchaseSmsPackageDto } from './dto/purchase-sms-package.dto';
import { PurchaseSubscriptionDto } from './dto/purchase-subscription.dto';
import { SalonSmsPurchase } from './entities/salon-sms-purchase.entity'; import { SalonSmsPurchase } from './entities/salon-sms-purchase.entity';
import { SalonSubscription } from './entities/salon-subscription.entity'; import { SalonSubscription } from './entities/salon-subscription.entity';
import { SalonSubscriptionStatus } from './enums/salon-subscription-status.enum'; import { SalonSubscriptionStatus } from './enums/salon-subscription-status.enum';
@@ -32,27 +31,22 @@ export class SalonSubscriptionsService {
private readonly subscriptionPolicy: SubscriptionPolicy, private readonly subscriptionPolicy: SubscriptionPolicy,
) {} ) {}
async purchaseSubscription( async fulfillSubscriptionPurchase(
dto: PurchaseSubscriptionDto, salonId: string,
user: AuthUser, planId: string,
): Promise<SalonSubscription> { ): Promise<SalonSubscription> {
const salon = await this.salonsService.findOne(dto.salonId); const activeSubscription = await this.findActiveSubscription(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);
if (activeSubscription) { if (activeSubscription) {
throw new BadRequestException('Salon already has an active subscription'); throw new BadRequestException('Salon already has an active subscription');
} }
const plan = await this.subscriptionPlansService.findActiveById(planId);
const now = new Date(); 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 freeSmsGranted = plan.freeSmsCount + rolloverSms;
const subscription = this.subscriptionsRepository.create({ const subscription = this.subscriptionsRepository.create({
salonId: dto.salonId, salonId,
planId: plan.id, planId: plan.id,
status: SalonSubscriptionStatus.ACTIVE, status: SalonSubscriptionStatus.ACTIVE,
startDate: now, startDate: now,
@@ -63,7 +57,28 @@ export class SalonSubscriptionsService {
}); });
const saved = await this.subscriptionsRepository.save(subscription); 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( async purchaseSmsPackage(
+4 -2
View File
@@ -1,8 +1,9 @@
import { Module } from '@nestjs/common'; import { Module, forwardRef } from '@nestjs/common';
import { APP_GUARD } from '@nestjs/core'; import { APP_GUARD } from '@nestjs/core';
import { TypeOrmModule } from '@nestjs/typeorm'; import { TypeOrmModule } from '@nestjs/typeorm';
import { AuthGuardsModule } from '../auth/auth-guards.module'; import { AuthGuardsModule } from '../auth/auth-guards.module';
import { AuthorizationModule } from '../authorization/authorization.module'; import { AuthorizationModule } from '../authorization/authorization.module';
import { PaymentsModule } from '../payments/payments.module';
import { SalonsModule } from '../salons/salons.module'; import { SalonsModule } from '../salons/salons.module';
import { SalonSmsPurchase } from './entities/salon-sms-purchase.entity'; import { SalonSmsPurchase } from './entities/salon-sms-purchase.entity';
import { SalonSubscription } from './entities/salon-subscription.entity'; import { SalonSubscription } from './entities/salon-subscription.entity';
@@ -27,6 +28,7 @@ import { SubscriptionPlansService } from './subscription-plans.service';
SalonsModule, SalonsModule,
AuthorizationModule, AuthorizationModule,
AuthGuardsModule, AuthGuardsModule,
forwardRef(() => PaymentsModule),
], ],
controllers: [ controllers: [
SubscriptionPlansController, SubscriptionPlansController,
@@ -42,6 +44,6 @@ import { SubscriptionPlansService } from './subscription-plans.service';
useClass: ActiveSubscriptionGuard, useClass: ActiveSubscriptionGuard,
}, },
], ],
exports: [SalonSubscriptionsService], exports: [SalonSubscriptionsService, SubscriptionPlansService],
}) })
export class SubscriptionsModule {} export class SubscriptionsModule {}