payment
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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: [],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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(
|
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(
|
||||||
|
|||||||
@@ -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 {}
|
||||||
|
|||||||
Reference in New Issue
Block a user