diff --git a/src/app.module.ts b/src/app.module.ts index f343e3a..f3f1456 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -1,4 +1,5 @@ import { FastifyMulterModule } from "@nest-lab/fastify-multer"; +import { HttpModule } from "@nestjs/axios"; import { CacheModule } from "@nestjs/cache-manager"; import { MiddlewareConsumer, Module, NestModule } from "@nestjs/common"; import { ConfigModule } from "@nestjs/config"; @@ -6,6 +7,7 @@ import { ThrottlerModule } from "@nestjs/throttler"; import { TypeOrmModule } from "@nestjs/typeorm"; import { cacheConfig } from "./configs/cache.config"; +// import { httpConfig } from "./configs/http.config"; import { rateLimitConfig } from "./configs/rateLimit.config"; import { databaseConfigs } from "./configs/typeorm.config"; import { HTTPLogger } from "./core/middlewares/logger.middleware"; @@ -14,10 +16,12 @@ import { AuthModule } from "./modules/auth/auth.module"; import { ContactUsModule } from "./modules/contact-us/contact-us.module"; import { CriticismModule } from "./modules/criticisms/criticisms.module"; import { DanakServicesModule } from "./modules/danak-services/danak-services.module"; +import { PaymentsModule } from "./modules/payments/payments.module"; import { SettingModule } from "./modules/settings/settings.module"; import { TicketsModule } from "./modules/tickets/tickets.module"; import { UploaderModule } from "./modules/uploader/uploader.module"; import { UsersModule } from "./modules/users/users.module"; +import { WalletsModule } from "./modules/wallets/wallets.module"; @Module({ imports: [ @@ -25,6 +29,7 @@ import { UsersModule } from "./modules/users/users.module"; ConfigModule.forRoot({ cache: true, isGlobal: true }), CacheModule.registerAsync(cacheConfig()), TypeOrmModule.forRootAsync(databaseConfigs()), + HttpModule.register({ global: true }), FastifyMulterModule, UsersModule, TicketsModule, @@ -35,6 +40,8 @@ import { UsersModule } from "./modules/users/users.module"; CriticismModule, SettingModule, ContactUsModule, + WalletsModule, + PaymentsModule, ], controllers: [], providers: [], diff --git a/src/common/decorators/user.decorator.ts b/src/common/decorators/user.decorator.ts index 8fca5f3..ae0189e 100644 --- a/src/common/decorators/user.decorator.ts +++ b/src/common/decorators/user.decorator.ts @@ -9,7 +9,14 @@ declare module "fastify" { } } -export const UserDec = createParamDecorator((_data: unknown, ctx: ExecutionContext) => { +export const UserDec = createParamDecorator((data: keyof Omit | undefined, ctx: ExecutionContext) => { const req = ctx.switchToHttp().getRequest(); - return req.user; + const user = req.user; + + return data ? user?.[data] : user; }); + +// export const UserDec = createParamDecorator((_data: unknown, ctx: ExecutionContext) => { +// const req = ctx.switchToHttp().getRequest(); +// return req.user; +// }); diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 6733435..310a48f 100644 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -179,6 +179,29 @@ export const enum TicketMessageEnum { MESSAGE_CREATED = "پیام تیکت با موفقیت ایجاد شد", TICKET_NOT_FOUND = "تیکت مورد نظر یافت نشد", CLOSED = "تیکت با موفقیت بسته شد", + TICKET_CLOSED = "تیکت قبلا بسته شده است", + ERROR_IN_TICKET_MSG_CREATION = "خطا در ایجاد پیام تیکت", +} + +export const enum WalletMessage { + WALLET_NOT_FOUND = "کیف پول یافت نشد", + AMOUNT_MUST_BE_INTEGER = "مبلغ شارژ باید یک عدد صحیح باشد", + AMOUNT_REQUIRED = "مبلغ شارژ مورد نیاز است", + DEPOSIT_TYPE_REQUIRED = "نوع شارژ مورد نیاز است", + AMOUNT_MINIMUM = "حداقل مبلغ شارژ ۱۰۰,۰۰۰ تومان است", + GATEWAY_REQUIRED = "درگاه پرداخت مورد نیاز است", + RECEIPT_URL_SHOULD_BE_URL = "آدرس فیش باید یک آدرس یو آر ال باشد", + RECEIPT_URL_REQUIRED = "آدرس فیش مورد نیاز است", + BANK_ACCOUNT_ID_REQUIRED = "شناسه حساب بانکی مورد نیاز است", + BANK_ACCOUNT_ID_SHOULD_BE_UUID = "شناسه حساب بانکی باید یک UUID معتبر باشد", + ERROR_IN_CHARGE_WALLET = "خطا در شارژ کیف پول", + DEPOSIT_WALLET_IPG = "شارژ کیف پول از طریق درگاه", +} + +export const enum PaymentMessage { + PAYMENT_GATEWAY_NOT_FOUND = "درگاه پرداخت یافت نشد", + ERROR_IN_PROCESS_PAYMENT = "خطا در پردازش پرداخت", + ERROR_IN_VERIFY_PAYMENT = "خطا در تایید پرداخت", } export const enum SettingMessageEnum { diff --git a/src/configs/zarinpal.config.ts b/src/configs/zarinpal.config.ts new file mode 100644 index 0000000..63e2d5c --- /dev/null +++ b/src/configs/zarinpal.config.ts @@ -0,0 +1,20 @@ +import { ConfigService } from "@nestjs/config"; + +export function zarinpalConfig() { + return { + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + merchantId: configService.getOrThrow("ZARINPAL_MERCHANT_ID"), + gatewayApiUrl: configService.getOrThrow("ZARINPAL_API_URL"), + callBackUrl: configService.getOrThrow("CALLBACK_URL"), + ipgType: configService.getOrThrow("IPG_TYPE"), + }), + }; +} + +export interface IZarinpalConfig { + merchantId: string; + gatewayApiUrl: string; + callBackUrl: string; + ipgType: string; +} diff --git a/src/modules/announcements/announcement.module.ts b/src/modules/announcements/announcement.module.ts index 6a0fa61..04d57b9 100644 --- a/src/modules/announcements/announcement.module.ts +++ b/src/modules/announcements/announcement.module.ts @@ -14,6 +14,6 @@ import { UserAnnouncementRepository } from "./repositories/user-announcement.rep imports: [TypeOrmModule.forFeature([Announcement, UserAnnouncement]), DanakServicesModule, UsersModule], providers: [AnnouncementService, AnnouncementRepository, UserAnnouncementRepository], controllers: [AnnouncementController], - exports: [TypeOrmModule], + exports: [AnnouncementService], }) export class AnnouncementsModule {} diff --git a/src/modules/auth/providers/auth.service.ts b/src/modules/auth/providers/auth.service.ts index 65ae8ae..645eca2 100644 --- a/src/modules/auth/providers/auth.service.ts +++ b/src/modules/auth/providers/auth.service.ts @@ -22,6 +22,7 @@ export class AuthService { private readonly tokensService: TokensService, ) {} //****************** */ + //****************** */ async initiateRegistration(requestOtpDto: RequestOtpDto) { const { phone } = requestOtpDto; const existUser = await this.usersService.findOneWithPhone(phone); @@ -44,6 +45,7 @@ export class AuthService { }; } //****************** */ + //****************** */ async completeRegistration(completeRegistrationDto: CompleteRegistrationDto) { const { phone, code } = completeRegistrationDto; const isValid = await this.otpService.verifyOtp(phone, code, "REGISTER"); @@ -62,7 +64,7 @@ export class AuthService { }; } //****************** */ - + //****************** */ async loginWithPassword(loginDto: LoginPasswordDTO) { const { email, password } = loginDto; @@ -76,6 +78,7 @@ export class AuthService { }; } //****************** */ + //****************** */ async adminLoginWithPassword(loginDto: LoginPasswordDTO) { const { email, password } = loginDto; @@ -92,6 +95,8 @@ export class AuthService { } //****************** */ + //****************** */ + async checkUserExist(checkUserExistDto: CheckUserExistDto) { const user = await this.usersService.findOneWithEmail(checkUserExistDto.email); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); @@ -99,6 +104,8 @@ export class AuthService { } //****************** */ + //****************** */ + async requestLoginOtp(requestOtpDto: RequestOtpDto, isAdmin: boolean = false) { const { phone } = requestOtpDto; const user = await this.usersService.findOneWithPhone(phone); @@ -126,6 +133,8 @@ export class AuthService { } //****************** */ + //****************** */ + async verifyLoginOtp(verifyOtpDto: VerifyOtpDto) { const { code, phone } = verifyOtpDto; @@ -139,6 +148,8 @@ export class AuthService { } //****************** */ + //****************** */ + async adminVerifyLoginOtp(verifyOtpDto: VerifyOtpDto) { const { code, phone } = verifyOtpDto; diff --git a/src/modules/criticisms/criticisms.module.ts b/src/modules/criticisms/criticisms.module.ts index ed3aae4..c89eb38 100644 --- a/src/modules/criticisms/criticisms.module.ts +++ b/src/modules/criticisms/criticisms.module.ts @@ -13,6 +13,6 @@ import { UsersModule } from "../users/users.module"; imports: [TypeOrmModule.forFeature([Criticism, CriticismFile]), UsersModule], providers: [CriticismsService, CriticismRepository, CriticismFileRepository], controllers: [CriticismController], - exports: [], + exports: [CriticismsService], }) export class CriticismModule {} diff --git a/src/modules/danak-services/danak-services.module.ts b/src/modules/danak-services/danak-services.module.ts index 2477b50..224c8d5 100644 --- a/src/modules/danak-services/danak-services.module.ts +++ b/src/modules/danak-services/danak-services.module.ts @@ -14,6 +14,6 @@ import { DanakServicesRepository } from "./repositories/danak-services.repositor imports: [TypeOrmModule.forFeature([DanakService, DanakServiceImage, DanakServiceCategory])], providers: [DanakServicesService, DanakServicesRepository, DanakServicesCategoryRepository, DanakServicesImageRepository], controllers: [DanakServicesController], - exports: [DanakServicesService, TypeOrmModule], + exports: [DanakServicesService], }) export class DanakServicesModule {} diff --git a/src/modules/payments/constants/index.ts b/src/modules/payments/constants/index.ts new file mode 100644 index 0000000..d79f558 --- /dev/null +++ b/src/modules/payments/constants/index.ts @@ -0,0 +1 @@ +export const ZARINPAL_CONFIG = "ZARINPAL_CONFIG"; diff --git a/src/modules/payments/entities/bank-account.entity.ts b/src/modules/payments/entities/bank-account.entity.ts new file mode 100644 index 0000000..a892899 --- /dev/null +++ b/src/modules/payments/entities/bank-account.entity.ts @@ -0,0 +1,21 @@ +import { Column, Entity } from "typeorm"; + +import { BaseEntity } from "../../../common/entities/base.entity"; + +@Entity() +export class BankAccount extends BaseEntity { + @Column({ type: "varchar", length: 100, unique: true, nullable: true }) + cardNumber: string; + + @Column({ type: "varchar", length: 100, unique: true, nullable: true }) + shebaNumber: string; + + @Column({ type: "varchar", length: 100, nullable: false }) + bankName: string; + + @Column({ type: "varchar", length: 150, nullable: false }) + accountOwnerName: string; + + @Column({ default: true }) + isActive: boolean; +} diff --git a/src/modules/payments/entities/payment-method.entity.ts b/src/modules/payments/entities/payment-method.entity.ts new file mode 100644 index 0000000..6a52fc1 --- /dev/null +++ b/src/modules/payments/entities/payment-method.entity.ts @@ -0,0 +1,28 @@ +import { Column, Entity, ManyToOne } from "typeorm"; + +import { BankAccount } from "./bank-account.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; + +export enum PaymentMethodType { + CARD_TO_CARD = "CARD_TO_CARD", + GATEWAY = "GATEWAY", + SHEBA = "SHEBA", +} + +@Entity() +export class PaymentMethod extends BaseEntity { + @Column({ type: "enum", enum: PaymentMethodType, default: PaymentMethodType.GATEWAY }) + type: PaymentMethodType; + + @ManyToOne(() => BankAccount, { nullable: true, onDelete: "SET NULL" }) + bankAccount?: BankAccount; + + @Column({ type: "varchar", length: 150, nullable: true }) + gatewayName?: string; + + @Column({ type: "text", nullable: true }) + description?: string; + + @Column({ type: "boolean", default: true }) + isActive: boolean; +} diff --git a/src/modules/payments/entities/payment.entity.ts b/src/modules/payments/entities/payment.entity.ts new file mode 100644 index 0000000..f2f3620 --- /dev/null +++ b/src/modules/payments/entities/payment.entity.ts @@ -0,0 +1,27 @@ +import { Column, Entity, ManyToOne } from "typeorm"; + +import { PaymentMethod } from "./payment-method.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; +import { User } from "../../users/entities/user.entity"; +import { PaymentStatus } from "../enums/payment-status.enum"; + +@Entity() +export class Payment extends BaseEntity { + @Column({ type: "decimal", precision: 10, scale: 2 }) + amount: number; + + @Column({ type: "varchar", length: 150, nullable: false }) + reference: string; + + @Column({ type: "varchar", length: 150, nullable: true }) + transactionId: string; + + @Column({ type: "enum", enum: PaymentStatus, default: PaymentStatus.PENDING }) + status: PaymentStatus; + + @ManyToOne(() => User, (user) => user.payments, { nullable: false, onDelete: "RESTRICT" }) + user: User; + + @ManyToOne(() => PaymentMethod, { nullable: false }) + paymentMethod: PaymentMethod; +} diff --git a/src/modules/payments/enums/gateway.enum.ts b/src/modules/payments/enums/gateway.enum.ts new file mode 100644 index 0000000..ae9eb46 --- /dev/null +++ b/src/modules/payments/enums/gateway.enum.ts @@ -0,0 +1,3 @@ +export enum GatewayEnum { + ZARINPAL = "zarinpal", +} diff --git a/src/modules/payments/enums/payment-status.enum.ts b/src/modules/payments/enums/payment-status.enum.ts new file mode 100644 index 0000000..4768a53 --- /dev/null +++ b/src/modules/payments/enums/payment-status.enum.ts @@ -0,0 +1,6 @@ +export enum PaymentStatus { + PENDING = "PENDING", + COMPLETED = "COMPLETED", + FAILED = "FAILED", + CANCELLED = "CANCELLED", +} diff --git a/src/modules/payments/factories/payment.factory.ts b/src/modules/payments/factories/payment.factory.ts new file mode 100644 index 0000000..179476f --- /dev/null +++ b/src/modules/payments/factories/payment.factory.ts @@ -0,0 +1,28 @@ +import { BadGatewayException, Injectable } from "@nestjs/common"; + +import { PaymentMessage } from "../../../common/enums/message.enum"; +import { GatewayEnum } from "../enums/gateway.enum"; +import { ZarinpalGateway } from "../gateways/zarinpal.gateway"; +import { GatewayType } from "../types/gateway.type"; + +@Injectable() +export class PaymentGatewayFactory { + constructor(private readonly ZarinpalGateway: ZarinpalGateway) {} + + //************************ * + getPaymentGateway(provider: GatewayType) { + switch (provider) { + case "zarinpal": + return this.ZarinpalGateway; + + default: + throw new BadGatewayException(PaymentMessage.PAYMENT_GATEWAY_NOT_FOUND); + } + } + + //************************ */ + getAvailablePaymentGateways() { + const gateways = [{ name: GatewayEnum.ZARINPAL }]; + return { gateways }; + } +} diff --git a/src/modules/payments/gateways/zarinpal.gateway.ts b/src/modules/payments/gateways/zarinpal.gateway.ts new file mode 100644 index 0000000..03086cb --- /dev/null +++ b/src/modules/payments/gateways/zarinpal.gateway.ts @@ -0,0 +1,96 @@ +import { HttpService } from "@nestjs/axios"; +import { Inject, Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; +import { AxiosError } from "axios"; +import { catchError, firstValueFrom } from "rxjs"; + +import { PaymentMessage } from "../../../common/enums/message.enum"; +import { IZarinpalConfig } from "../../../configs/zarinpal.config"; +import { ZARINPAL_CONFIG } from "../constants"; +import { GatewayEnum } from "../enums/gateway.enum"; +import { + IPaymentGateway, + IPaymentVerifyParams, + IProcessPaymentParams, + ZarinPalPGNewArgs, + ZarinPalPGNewRequestData, + ZarinPalPGVerifyData, +} from "../interfaces/IPayment"; + +@Injectable() +export class ZarinpalGateway implements IPaymentGateway { + private readonly logger = new Logger(ZarinpalGateway.name); + private readonly gatewayApiUrl: string; + private readonly requestHeader: Record = { "Content-Type": "application/json", Accept: "application/json" }; + + constructor( + @Inject(ZARINPAL_CONFIG) private readonly config: IZarinpalConfig, + private readonly httpService: HttpService, + ) { + this.gatewayApiUrl = `https://${this.config.ipgType}.${this.config.gatewayApiUrl}`; + } + //*************************************** */ + async processPayment(processParams: IProcessPaymentParams) { + try { + const purchaseData: ZarinPalPGNewArgs = { + merchant_id: this.config.merchantId, + amount: processParams.amount, + callback_url: `${this.config.callBackUrl}/${GatewayEnum.ZARINPAL}`, + description: processParams.description, + currency: "IRT", + metadata: { email: processParams.email, mobile: processParams.mobile }, + }; + + const { data } = await firstValueFrom( + this.httpService + .post(`${this.gatewayApiUrl}/v4/payment/request.json`, purchaseData, { + headers: this.requestHeader, + }) + .pipe( + catchError((err: AxiosError) => { + this.logger.error(err); + throw new InternalServerErrorException(PaymentMessage.ERROR_IN_PROCESS_PAYMENT); + }), + ), + ); + + return { + redirectUrl: `${this.gatewayApiUrl}/StartPay/${data.data.authority}`, + message: data.data.message, + reference: data.data.authority, + }; + } catch (error) { + this.logger.error(error); + throw new InternalServerErrorException(PaymentMessage.ERROR_IN_PROCESS_PAYMENT); + } + } + //********************************** */ + async verifyPayment(verifyParams: IPaymentVerifyParams) { + try { + const verifyData = { authority: verifyParams.reference, amount: verifyParams.amount, merchant_id: this.config.merchantId }; + + const { data } = await firstValueFrom( + this.httpService + .post(`${this.config.gatewayApiUrl}/v4/payment/verify.json`, verifyData, { headers: this.requestHeader }) + .pipe( + catchError((err: AxiosError) => { + this.logger.error(err); + throw new InternalServerErrorException(PaymentMessage.ERROR_IN_VERIFY_PAYMENT); + }), + ), + ); + + return { + code: data.data.code, + message: data.data.message, + card_hash: data.data.card_hash, + card_pan: data.data.card_pan, + ref_id: data.data.ref_id, + fee_type: data.data.fee_type, + fee: data.data.fee, + }; + } catch (error) { + this.logger.error(error); + throw new InternalServerErrorException(PaymentMessage.ERROR_IN_VERIFY_PAYMENT); + } + } +} diff --git a/src/modules/payments/interfaces/IPayment.ts b/src/modules/payments/interfaces/IPayment.ts new file mode 100644 index 0000000..d9e1276 --- /dev/null +++ b/src/modules/payments/interfaces/IPayment.ts @@ -0,0 +1,84 @@ +//------------------ process payment ------------------------- + +export interface IProcessPaymentParams { + amount: number; + description: string; + // callBackPath: string; + email?: string; + mobile?: string; +} + +export interface IProcessPaymentData { + redirectUrl: string; + message: string; + reference: string; +} + +//------------------ Verify payment ------------------------- + +//TODO: Define the IPaymentVerifyData interface +export interface IPaymentVerifyData { + [x: string]: unknown; +} + +export interface IPaymentVerifyParams { + amount: number; + reference: string; +} + +//--------------------- zarinpal -------------------------------- +interface MetaData { + mobile?: string; + email?: string; + order_id?: string; +} + +export interface ZarinPalPGNewArgs { + merchant_id: string; + amount: number; + description: string; + callback_url: string; + metadata?: MetaData; + currency?: "IRR" | "IRT"; +} + +interface IZarinPalPGRequestData { + code: number; + message: string; + authority: string; + fee_type: string; + fee: number; +} + +export interface ZarinPalPGNewRequestData { + data: IZarinPalPGRequestData; + error: string[]; +} + +interface IZarinpalPaymentVerifyData { + code: number; + message: string; + card_hash: string; + card_pan: string; + ref_id: number; + fee_type: string; + fee: number; +} + +export interface ZarinPalPGVerifyData { + data: IZarinpalPaymentVerifyData; + error: string[]; +} + +//********************************************** */ +//------------------abstract class---------------- + +export interface IPaymentGateway { + processPayment(processPaymentParam: IProcessPaymentParams): Promise; + verifyPayment(verifyPaymentParam: IPaymentVerifyParams): Promise; +} + +// export abstract class PaymentGateway { +// abstract processPayment(processPaymentParam: IProcessPaymentParams): Promise; +// abstract verifyPayment(verifyPaymentParam: IPaymentVerifyParams): Promise; +// } diff --git a/src/modules/payments/payments.controller.ts b/src/modules/payments/payments.controller.ts new file mode 100644 index 0000000..334e6fd --- /dev/null +++ b/src/modules/payments/payments.controller.ts @@ -0,0 +1,18 @@ +import { Controller, Get } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { PaymentsService } from "./providers/payments.service"; +import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; + +@Controller("payments") +@ApiTags("Payments") +export class PaymentsController { + constructor(private readonly paymentsService: PaymentsService) {} + + @AuthGuards() + @ApiOperation({ summary: "get all available gateways" }) + @Get("gateways") + getAvailableGateways() { + return this.paymentsService.getAvailableGateways(); + } +} diff --git a/src/modules/payments/payments.module.ts b/src/modules/payments/payments.module.ts new file mode 100644 index 0000000..2d5fe2a --- /dev/null +++ b/src/modules/payments/payments.module.ts @@ -0,0 +1,33 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { ZARINPAL_CONFIG } from "./constants"; +import { BankAccount } from "./entities/bank-account.entity"; +import { PaymentMethod } from "./entities/payment-method.entity"; +import { Payment } from "./entities/payment.entity"; +import { PaymentGatewayFactory } from "./factories/payment.factory"; +import { ZarinpalGateway } from "./gateways/zarinpal.gateway"; +import { PaymentsController } from "./payments.controller"; +import { PaymentsService } from "./providers/payments.service"; +import { BankAccountsRepository } from "./repositories/bank-accounts.repository"; +import { PaymentsRepository } from "./repositories/payments.repository"; +import { zarinpalConfig } from "../../configs/zarinpal.config"; + +@Module({ + imports: [TypeOrmModule.forFeature([Payment, PaymentMethod, BankAccount])], + controllers: [PaymentsController], + providers: [ + PaymentsService, + PaymentGatewayFactory, + ZarinpalGateway, + PaymentsRepository, + BankAccountsRepository, + { + provide: ZARINPAL_CONFIG, + useFactory: zarinpalConfig().useFactory, + inject: zarinpalConfig().inject, + }, + ], + exports: [PaymentsService], +}) +export class PaymentsModule {} diff --git a/src/modules/payments/providers/payments.service.ts b/src/modules/payments/providers/payments.service.ts new file mode 100644 index 0000000..5a59568 --- /dev/null +++ b/src/modules/payments/providers/payments.service.ts @@ -0,0 +1,42 @@ +import { Injectable } from "@nestjs/common"; +import { QueryRunner } from "typeorm"; + +import { User } from "../../users/entities/user.entity"; +// import { PaymentMethod } from "../entities/payment-method.entity"; +import { Payment } from "../entities/payment.entity"; +import { PaymentGatewayFactory } from "../factories/payment.factory"; +// import { PaymentsRepository } from "../repositories/payments.repository"; +import { GatewayType } from "../types/gateway.type"; + +@Injectable() +export class PaymentsService { + constructor( + private readonly gatewayFactory: PaymentGatewayFactory, + // private readonly paymentsRepository: PaymentsRepository, + ) {} + //*********************************** */ + //*********************************** */ + async processPayment(provider: GatewayType, amount: number, description: string, email?: string, mobile?: string) { + const paymentGateway = this.gatewayFactory.getPaymentGateway(provider); + return paymentGateway.processPayment({ amount, description, email, mobile }); + } + //*********************************** */ + //*********************************** */ + async createPaymentForUser(user: User, amount: number, reference: string, queryRunner: QueryRunner) { + // const paymentMethod = queryRunner.manager.create(PaymentMethod, {}); + + const payment = queryRunner.manager.create(Payment, { + amount, + reference, + user, + }); + + await queryRunner.manager.save(Payment, payment); + return payment; + } + //*********************************** */ + //*********************************** */ + async getAvailableGateways() { + return this.gatewayFactory.getAvailablePaymentGateways(); + } +} diff --git a/src/modules/payments/repositories/bank-accounts.repository.ts b/src/modules/payments/repositories/bank-accounts.repository.ts new file mode 100644 index 0000000..9c55b63 --- /dev/null +++ b/src/modules/payments/repositories/bank-accounts.repository.ts @@ -0,0 +1,12 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { BankAccount } from "../entities/bank-account.entity"; + +@Injectable() +export class BankAccountsRepository extends Repository { + constructor(@InjectRepository(BankAccount) bankAccountsRepository: Repository) { + super(bankAccountsRepository.target, bankAccountsRepository.manager, bankAccountsRepository.queryRunner); + } +} diff --git a/src/modules/payments/repositories/payment-methods.repository.ts b/src/modules/payments/repositories/payment-methods.repository.ts new file mode 100644 index 0000000..9b5f918 --- /dev/null +++ b/src/modules/payments/repositories/payment-methods.repository.ts @@ -0,0 +1,11 @@ +import { Injectable } from "@nestjs/common"; +import { Repository } from "typeorm"; + +import { PaymentMethod } from "../entities/payment-method.entity"; + +@Injectable() +export class PaymentMethodsRepository extends Repository { + constructor(paymentMethodsRepository: Repository) { + super(paymentMethodsRepository.target, paymentMethodsRepository.manager, paymentMethodsRepository.queryRunner); + } +} diff --git a/src/modules/payments/repositories/payments.repository.ts b/src/modules/payments/repositories/payments.repository.ts new file mode 100644 index 0000000..23d2954 --- /dev/null +++ b/src/modules/payments/repositories/payments.repository.ts @@ -0,0 +1,12 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { Payment } from "../entities/payment.entity"; + +@Injectable() +export class PaymentsRepository extends Repository { + constructor(@InjectRepository(Payment) paymentsRepository: Repository) { + super(paymentsRepository.target, paymentsRepository.manager, paymentsRepository.queryRunner); + } +} diff --git a/src/modules/payments/types/gateway.type.ts b/src/modules/payments/types/gateway.type.ts new file mode 100644 index 0000000..856119c --- /dev/null +++ b/src/modules/payments/types/gateway.type.ts @@ -0,0 +1 @@ +export type GatewayType = "zarinpal"; diff --git a/src/modules/tickets/DTO/create-ticket-message.dto.ts b/src/modules/tickets/DTO/create-ticket-message.dto.ts index 481bb27..a7543b7 100644 --- a/src/modules/tickets/DTO/create-ticket-message.dto.ts +++ b/src/modules/tickets/DTO/create-ticket-message.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsNotEmpty, IsOptional, IsString, IsUrl } from "class-validator"; +import { ArrayMinSize, IsNotEmpty, IsOptional, IsString, IsUrl } from "class-validator"; import { TicketMessageEnum } from "../../../common/enums/message.enum"; @@ -11,7 +11,8 @@ export class CreateTicketMessageDto { @IsOptional() @IsNotEmpty({ message: TicketMessageEnum.ATTACHMENT_REQUIRED }) - @IsUrl({ protocols: ["http", "https"], require_protocol: true }, { message: TicketMessageEnum.ATTACHMENT_SHOULD_BE_URL }) - @ApiProperty({ description: "attachment url", example: "https://example.com/test.png" }) - attachmentUrl: string; + @ArrayMinSize(1, { message: TicketMessageEnum.ATTACHMENT_REQUIRED }) + @IsUrl({ protocols: ["http", "https"], require_protocol: true }, { each: true, message: TicketMessageEnum.ATTACHMENT_SHOULD_BE_URL }) + @ApiProperty({ description: "attachment url", example: ["https://example.com/test.png"] }) + attachmentUrls?: string[]; } diff --git a/src/modules/tickets/DTO/create-ticket.dto.ts b/src/modules/tickets/DTO/create-ticket.dto.ts index 11f25a7..4f60fc2 100644 --- a/src/modules/tickets/DTO/create-ticket.dto.ts +++ b/src/modules/tickets/DTO/create-ticket.dto.ts @@ -1,15 +1,15 @@ import { ApiProperty } from "@nestjs/swagger"; -import { IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID, Length } from "class-validator"; +import { ArrayMinSize, IsEnum, IsNotEmpty, IsOptional, IsString, IsUUID, IsUrl, Length } from "class-validator"; import { TicketMessageEnum } from "../../../common/enums/message.enum"; import { TicketPriority } from "../enums/ticket-priority.enum"; export class CreateTicketDto { - @IsNotEmpty({ message: TicketMessageEnum.TITLE_REQUIRED }) - @IsString({ message: TicketMessageEnum.TITLE_STRING }) - @Length(3, 150, { message: TicketMessageEnum.TITLE_LENGTH }) - @ApiProperty({ description: "The ticket title", example: "Test title" }) - title: string; + // @IsNotEmpty({ message: TicketMessageEnum.TITLE_REQUIRED }) + // @IsString({ message: TicketMessageEnum.TITLE_STRING }) + // @Length(3, 150, { message: TicketMessageEnum.TITLE_LENGTH }) + // @ApiProperty({ description: "The ticket title", example: "Test title" }) + // title: string; @IsNotEmpty({ message: TicketMessageEnum.SUBJECT_REQUIRED }) @IsString({ message: TicketMessageEnum.SUBJECT_STRING }) @@ -22,11 +22,11 @@ export class CreateTicketDto { @ApiProperty({ enum: TicketPriority, example: TicketPriority.LOW, description: "LOW, MEDIUM, HIGH" }) priority: TicketPriority; - @IsOptional() - @IsNotEmpty({ message: TicketMessageEnum.DANAK_SERVICE_ID_REQUIRED }) - @IsUUID("4", { message: TicketMessageEnum.DANAK_SERVICE_ID_SHOULD_BE_UUID }) - @ApiProperty({ description: "Service ID related to the ticket", example: "550e8400-e29b-41d4-a716-446655440000" }) - danakServiceId?: string; + // @IsOptional() + // @IsNotEmpty({ message: TicketMessageEnum.DANAK_SERVICE_ID_REQUIRED }) + // @IsUUID("4", { message: TicketMessageEnum.DANAK_SERVICE_ID_SHOULD_BE_UUID }) + // @ApiProperty({ description: "Service ID related to the ticket", example: "550e8400-e29b-41d4-a716-446655440000" }) + // danakServiceId?: string; @IsNotEmpty({ message: TicketMessageEnum.CATEGORY_ID_REQUIRED }) @IsUUID("4", { message: TicketMessageEnum.CATEGORY_ID_SHOULD_BE_UUID }) @@ -38,4 +38,11 @@ export class CreateTicketDto { @Length(5, 500, { message: TicketMessageEnum.MESSAGE_LENGTH }) @ApiProperty({ description: "The message content of the ticket", example: "This is a detailed description of the issue." }) message: string; + + @IsOptional() + @IsNotEmpty({ message: TicketMessageEnum.ATTACHMENT_REQUIRED }) + @ArrayMinSize(1, { message: TicketMessageEnum.ATTACHMENT_REQUIRED }) + @IsUrl({ protocols: ["http", "https"], require_protocol: true }, { each: true, message: TicketMessageEnum.ATTACHMENT_SHOULD_BE_URL }) + @ApiProperty({ description: "attachment url", example: ["https://example.com/test.png"] }) + attachmentUrls?: string[]; } diff --git a/src/modules/tickets/entities/ticket-message-attachment.ts b/src/modules/tickets/entities/ticket-message-attachment.ts new file mode 100644 index 0000000..c7ed4a8 --- /dev/null +++ b/src/modules/tickets/entities/ticket-message-attachment.ts @@ -0,0 +1,13 @@ +import { Column, Entity, ManyToOne } from "typeorm"; + +import { TicketMessage } from "./ticket-message.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; + +@Entity() +export class TicketMessageAttachment extends BaseEntity { + @Column({ type: "varchar", length: 150, nullable: true, default: null }) + attachmentUrl: string; + + @ManyToOne(() => TicketMessage, (ticketMessage) => ticketMessage.attachments, { onDelete: "CASCADE", nullable: false }) + ticketMessage: TicketMessage; +} diff --git a/src/modules/tickets/entities/ticket-message.entity.ts b/src/modules/tickets/entities/ticket-message.entity.ts index 66df757..a668ff3 100644 --- a/src/modules/tickets/entities/ticket-message.entity.ts +++ b/src/modules/tickets/entities/ticket-message.entity.ts @@ -1,5 +1,6 @@ -import { Column, Entity, JoinColumn, ManyToOne } from "typeorm"; +import { Column, Entity, JoinColumn, ManyToOne, OneToMany } from "typeorm"; +import { TicketMessageAttachment } from "./ticket-message-attachment"; import { Ticket } from "./ticket.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; import { User } from "../../users/entities/user.entity"; @@ -9,8 +10,8 @@ export class TicketMessage extends BaseEntity { @Column({ type: "text", nullable: false }) content: string; - @Column({ type: "varchar", length: 150, nullable: true, default: null }) - attachmentUrl: string; + @OneToMany(() => TicketMessageAttachment, (attachment) => attachment.ticketMessage, { cascade: true }) + attachments: TicketMessageAttachment[]; @JoinColumn() @ManyToOne(() => Ticket, (ticket) => ticket.messages, { onDelete: "CASCADE", nullable: false }) diff --git a/src/modules/tickets/entities/ticket.entity.ts b/src/modules/tickets/entities/ticket.entity.ts index 5942d98..d1e5299 100644 --- a/src/modules/tickets/entities/ticket.entity.ts +++ b/src/modules/tickets/entities/ticket.entity.ts @@ -13,8 +13,8 @@ export class Ticket extends BaseEntity { @Column({ type: "int", generated: "identity", insert: false }) numericId: number; - @Column({ type: "varchar", length: 150, nullable: false }) - title: string; + // @Column({ type: "varchar", length: 150, nullable: false }) + // title: string; @Column({ type: "varchar", length: 150, nullable: false }) subject: string; diff --git a/src/modules/tickets/providers/tickets.service.ts b/src/modules/tickets/providers/tickets.service.ts index 5af0739..91b9204 100644 --- a/src/modules/tickets/providers/tickets.service.ts +++ b/src/modules/tickets/providers/tickets.service.ts @@ -1,9 +1,10 @@ -import { BadRequestException, Injectable } from "@nestjs/common"; -import { Not } from "typeorm"; +import { BadRequestException, HttpException, Injectable, InternalServerErrorException } from "@nestjs/common"; +import { DataSource, Not } from "typeorm"; import { ParamDto } from "../../../common/DTO/param.dto"; -import { CommonMessage, TicketMessageEnum } from "../../../common/enums/message.enum"; +import { CommonMessage, TicketMessageEnum, UserMessage } from "../../../common/enums/message.enum"; import { Role } from "../../users/entities/role.entity"; +import { User } from "../../users/entities/user.entity"; import { RoleEnum } from "../../users/enums/role.enum"; import { UsersService } from "../../users/providers/users.service"; import { PaginationUtils } from "../../utils/providers/pagination.utils"; @@ -13,6 +14,7 @@ import { CreateTicketDto } from "../DTO/create-ticket.dto"; import { SearchTicketCategoryDto } from "../DTO/search-ticket-category.dto"; import { SearchTicketQueryDto } from "../DTO/search-ticket-query.dto"; import { UpdateTicketCategoryDto } from "../DTO/update-ticket-category.dto"; +import { TicketMessageAttachment } from "../entities/ticket-message-attachment"; import { TicketMessage } from "../entities/ticket-message.entity"; import { Ticket } from "../entities/ticket.entity"; import { TicketStatus } from "../enums/ticket-status.enum"; @@ -27,6 +29,7 @@ export class TicketsService { private readonly ticketsRepository: TicketsRepository, private readonly ticketMessagesRepository: TicketMessagesRepository, private readonly usersService: UsersService, + private dataSource: DataSource, ) {} //******************************** */ async getTicketCategories() { @@ -109,15 +112,26 @@ export class TicketsService { const { user } = await this.usersService.findOneById(userId); const ticketCategory = await this.ticketsCategoryRepository.findCategoryById(createDto.categoryId); if (!ticketCategory) throw new BadRequestException(TicketMessageEnum.CATEGORY_NOT_FOUND); + const { attachmentUrls } = createDto; // + const ticketMessage: Partial = { content: createDto.message, author: user }; + + if (attachmentUrls) { + ticketMessage.attachments = attachmentUrls.map((url) => { + const attachment = new TicketMessageAttachment(); + attachment.attachmentUrl = url; + return attachment; + }); + } const ticket = this.ticketsRepository.create({ ...createDto, user, category: ticketCategory, messages: [ticketMessage] }); await this.ticketsRepository.save(ticket); return { message: TicketMessageEnum.CREATED, + ticketId: ticket.id, }; } @@ -146,31 +160,83 @@ export class TicketsService { return { tickets, count, paginate: true }; } + //******************************** */ + //TODO: get for admin with group of category + async getAllTickets(queryDto: SearchTicketQueryDto) { + const { limit, skip } = PaginationUtils(queryDto); + + const findOptions = { + where: { + ...(queryDto.status && { status: queryDto.status }), + }, + relations: { + category: true, + user: true, + }, + order: { + createdAt: "DESC" as const, + }, + skip, + take: limit, + }; + + const [tickets, count] = await this.ticketsRepository.findAndCount(findOptions); + + return { tickets, count, paginate: true }; + } //******************************** */ async createTicketMessage(ticketId: string, createDto: CreateTicketMessageDto, userId: string, role: Role) { - const userRole = role as unknown as RoleEnum; - let ticket: null | Ticket = null; + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); - if (userRole === RoleEnum.ADMIN) { - ticket = await this.ticketsRepository.findTicketById(ticketId); - } else { - ticket = await this.ticketsRepository.findTicketById(ticketId, userId); + try { + const userRole = role as unknown as RoleEnum; + let ticket: null | Ticket = null; + + if (userRole === RoleEnum.ADMIN) { + ticket = await queryRunner.manager.findOneBy(Ticket, { id: ticketId }); + } else { + ticket = await queryRunner.manager.findOneBy(Ticket, { id: ticketId, user: { id: userId } }); + } + + if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND); + + if (ticket.status === TicketStatus.CLOSED) throw new BadRequestException(TicketMessageEnum.TICKET_CLOSED); + + const user = await queryRunner.manager.findOneBy(User, { id: userId }); + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + + const ticketMessage = queryRunner.manager.create(TicketMessage, { ...createDto, author: user, ticket }); + + if (userRole === RoleEnum.ADMIN && ticket.status === TicketStatus.PENDING) { + ticket.status = TicketStatus.ANSWERED; + } + + await queryRunner.manager.save(Ticket, ticket); + await queryRunner.manager.save(TicketMessage, ticketMessage); + + if (createDto.attachmentUrls) { + const attachments = createDto.attachmentUrls.map((url) => + queryRunner.manager.create(TicketMessageAttachment, { attachmentUrl: url, ticketMessage }), + ); + await queryRunner.manager.save(TicketMessageAttachment, attachments); + } + + await queryRunner.commitTransaction(); + return { + message: TicketMessageEnum.MESSAGE_CREATED, + ticketMessage, + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + if (error instanceof HttpException) throw error; + throw new InternalServerErrorException(TicketMessageEnum.ERROR_IN_TICKET_MSG_CREATION); + } finally { + await queryRunner.release(); } - - if (!ticket) throw new BadRequestException(TicketMessageEnum.TICKET_NOT_FOUND); - - const { user } = await this.usersService.findOneById(userId); - - const ticketMessage = this.ticketMessagesRepository.create({ ...createDto, author: user, ticket }); - - await this.ticketMessagesRepository.save(ticketMessage); - - return { - message: TicketMessageEnum.MESSAGE_CREATED, - // ticketMessage, - }; } //******************************** */ diff --git a/src/modules/tickets/repositories/ticket-message-attachment.repository.ts b/src/modules/tickets/repositories/ticket-message-attachment.repository.ts new file mode 100644 index 0000000..028f009 --- /dev/null +++ b/src/modules/tickets/repositories/ticket-message-attachment.repository.ts @@ -0,0 +1,16 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { TicketMessageAttachment } from "../entities/ticket-message-attachment"; + +@Injectable() +export class TicketMessageAttachmentsRepository extends Repository { + constructor(@InjectRepository(TicketMessageAttachment) ticketMessageAttachmentsRepository: Repository) { + super( + ticketMessageAttachmentsRepository.target, + ticketMessageAttachmentsRepository.manager, + ticketMessageAttachmentsRepository.queryRunner, + ); + } +} diff --git a/src/modules/tickets/repositories/tickets-message.repository.ts b/src/modules/tickets/repositories/tickets-message.repository.ts index 1c028f8..97b6602 100644 --- a/src/modules/tickets/repositories/tickets-message.repository.ts +++ b/src/modules/tickets/repositories/tickets-message.repository.ts @@ -14,12 +14,16 @@ export class TicketMessagesRepository extends Repository { return this.find({ relations: { author: true, + attachments: true, // ticket: true, }, select: { id: true, content: true, - attachmentUrl: true, + attachments: { + id: true, + attachmentUrl: true, + }, createdAt: true, author: { id: true, @@ -32,7 +36,7 @@ export class TicketMessagesRepository extends Repository { }, }, where: { ticket: { id: ticketId } }, - order: { createdAt: "DESC" }, + order: { createdAt: "ASC" }, }); } } diff --git a/src/modules/tickets/repositories/tickets.repository.ts b/src/modules/tickets/repositories/tickets.repository.ts index 3dd4eda..0a7e22c 100644 --- a/src/modules/tickets/repositories/tickets.repository.ts +++ b/src/modules/tickets/repositories/tickets.repository.ts @@ -11,6 +11,40 @@ export class TicketsRepository extends Repository { } async findTicketById(id: string, userId?: string): Promise { - return this.findOneBy({ id, ...(userId && { user: { id: userId } }) }); + return this.findOne({ + where: { id, ...(userId && { user: { id: userId } }) }, + relations: { + user: true, + category: true, + assignedTo: true, + danakService: true, + }, + select: { + id: true, + numericId: true, + danakService: { + id: true, + name: true, + }, + subject: true, + priority: true, + status: true, + createdAt: true, + user: { + id: true, + firstName: true, + lastName: true, + }, + assignedTo: { + id: true, + firstName: true, + lastName: true, + }, + category: { + id: true, + title: true, + }, + }, + }); } } diff --git a/src/modules/tickets/tickets.controller.ts b/src/modules/tickets/tickets.controller.ts index a63fff3..b28ec74 100644 --- a/src/modules/tickets/tickets.controller.ts +++ b/src/modules/tickets/tickets.controller.ts @@ -71,8 +71,8 @@ export class TicketsController { @AuthGuards() @Roles(RoleEnum.USER) @Post() - createTicket(@Body() createDto: CreateTicketDto, @UserDec() user: User) { - return this.ticketsService.createTicket(createDto, user.id); + createTicket(@Body() createDto: CreateTicketDto, @UserDec("id") userId: string) { + return this.ticketsService.createTicket(createDto, userId); } @ApiOperation({ summary: "Get all tickets of user ==> user route" }) @@ -80,8 +80,17 @@ export class TicketsController { @Roles(RoleEnum.USER) @Pagination() @Get() - getTickets(@Query() queryDto: SearchTicketQueryDto, @UserDec() user: User) { - return this.ticketsService.getTickets(queryDto, user.id); + getTickets(@Query() queryDto: SearchTicketQueryDto, @UserDec("id") userId: string) { + return this.ticketsService.getTickets(queryDto, userId); + } + + @ApiOperation({ summary: "Get all tickets ==> admin route" }) + @AuthGuards() + @Roles(RoleEnum.ADMIN) + @Pagination() + @Get("admin") + getAllTickets(@Query() queryDto: SearchTicketQueryDto) { + return this.ticketsService.getAllTickets(queryDto); } @ApiOperation({ summary: "create ticket messages" }) @@ -103,7 +112,8 @@ export class TicketsController { @ApiOperation({ summary: "close ticket by user or admin" }) @AuthGuards() @Roles(RoleEnum.USER, RoleEnum.ADMIN) - @Get(":id/close") + @HttpCode(HttpStatus.OK) + @Post(":id/close") closedTicketByUser(@Param() paramDto: ParamDto, @UserDec() user: User) { return this.ticketsService.closeTicketByUser(paramDto.id, user.id, user.role); } diff --git a/src/modules/tickets/tickets.module.ts b/src/modules/tickets/tickets.module.ts index e8a459a..04cb158 100644 --- a/src/modules/tickets/tickets.module.ts +++ b/src/modules/tickets/tickets.module.ts @@ -10,11 +10,13 @@ import { TicketMessagesRepository } from "./repositories/tickets-message.reposit import { TicketsRepository } from "./repositories/tickets.repository"; import { TicketsController } from "./tickets.controller"; import { UsersModule } from "../users/users.module"; +import { TicketMessageAttachment } from "./entities/ticket-message-attachment"; +import { TicketMessageAttachmentsRepository } from "./repositories/ticket-message-attachment.repository"; @Module({ - imports: [TypeOrmModule.forFeature([Ticket, TicketCategory, TicketMessage]), UsersModule], - providers: [TicketsService, TicketsRepository, TicketCategoryRepository, TicketMessagesRepository], + imports: [TypeOrmModule.forFeature([Ticket, TicketCategory, TicketMessage, TicketMessageAttachment]), UsersModule], + providers: [TicketsService, TicketsRepository, TicketCategoryRepository, TicketMessagesRepository, TicketMessageAttachmentsRepository], controllers: [TicketsController], - exports: [TypeOrmModule, TicketsService, TicketsRepository, TicketCategoryRepository, TicketMessagesRepository], + exports: [TicketsService], }) export class TicketsModule {} diff --git a/src/modules/users/entities/user.entity.ts b/src/modules/users/entities/user.entity.ts index 70d9454..c069037 100644 --- a/src/modules/users/entities/user.entity.ts +++ b/src/modules/users/entities/user.entity.ts @@ -1,5 +1,5 @@ import { Exclude } from "class-transformer"; -import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany } from "typeorm"; +import { Column, Entity, JoinTable, ManyToMany, ManyToOne, OneToMany, OneToOne } from "typeorm"; import { Role } from "./role.entity"; import { UserGroup } from "./user-group.entity"; @@ -7,9 +7,11 @@ import { BaseEntity } from "../../../common/entities/base.entity"; import { UserAnnouncement } from "../../announcements/entities/user-announcement.entity"; import { Criticism } from "../../criticisms/entities/criticism.entity"; import { DanakService } from "../../danak-services/entities/danak-service.entity"; +import { Payment } from "../../payments/entities/payment.entity"; import { UserSetting } from "../../settings/entities/user-setting.entity"; import { TicketMessage } from "../../tickets/entities/ticket-message.entity"; import { Ticket } from "../../tickets/entities/ticket.entity"; +import { Wallet } from "../../wallets/entities/wallet.entity"; @Entity() export class User extends BaseEntity { @@ -62,4 +64,9 @@ export class User extends BaseEntity { @OneToMany(() => UserSetting, (settings) => settings.user, { nullable: false }) settings: UserSetting; + @OneToOne(() => Wallet, (wallet) => wallet.user) + wallet: Wallet; + + @OneToMany(() => Payment, (payment) => payment.user) + payments: Payment[]; } diff --git a/src/modules/users/providers/users.service.ts b/src/modules/users/providers/users.service.ts index 4b9f807..7c8b417 100644 --- a/src/modules/users/providers/users.service.ts +++ b/src/modules/users/providers/users.service.ts @@ -58,8 +58,6 @@ export class UsersService { /************************************************************ */ async updateProfile(userId: string, updateProfileDto: UpdateProfileDto) { - const { firstName, lastName, birthDate } = updateProfileDto; - if (updateProfileDto.userName) { updateProfileDto.userName = slugify(updateProfileDto.userName, { lower: true, trim: true }); const existUserName = await this.userRepository.findOneBy({ userName: updateProfileDto.userName, id: Not(userId) }); @@ -68,13 +66,12 @@ export class UsersService { const user = await this.userRepository.findOneBy({ id: userId }); if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); - const { userName } = updateProfileDto; - if (userName) user.userName = userName; - if (firstName) user.firstName = firstName; - if (lastName) user.lastName = lastName; - if (birthDate) user.birthDate = birthDate; + // const { userName } = updateProfileDto; + // if (userName) user.userName = userName; + // if (firstName) user.firstName = firstName; + // if (lastName) user.lastName = lastName; - await this.userRepository.save(user); + await this.userRepository.save({ ...user, ...updateProfileDto }); return { message: CommonMessage.UPDATE_SUCCESS, }; @@ -95,24 +92,17 @@ export class UsersService { /************************************************************ */ async createUser(registerDto: CompleteRegistrationDto, hashedPassword: string): Promise { - // const existEmail = await this.userRepository.findOneWithEmail(registerDto.email); - // if (existEmail) throw new BadRequestException(UserMessage.EMAIL_EXIST); - const queryRunner = this.dataSource.createQueryRunner(); - await queryRunner.connect(); // Establish a connection - await queryRunner.startTransaction(); // Start the transaction + await queryRunner.connect(); + await queryRunner.startTransaction(); try { - // Find the USER role const role = await queryRunner.manager.findOne(Role, { where: { name: RoleEnum.USER }, }); - if (!role) { - throw new BadRequestException(UserMessage.ROLE_NOT_FOUND); - } + if (!role) throw new BadRequestException(UserMessage.ROLE_NOT_FOUND); - // Create and save the User entity const user = queryRunner.manager.create(User, { ...registerDto, password: hashedPassword, @@ -122,10 +112,9 @@ export class UsersService { await this.userSettingsService.createUserSettings(user.id, queryRunner); - // Commit the transaction await queryRunner.commitTransaction(); - return user; // Return the created user + return user; } catch (error) { await queryRunner.rollbackTransaction(); throw error; @@ -151,6 +140,7 @@ export class UsersService { return { message: CommonMessage.VALID_FOR_CHOOSE }; } + /************************************************************ */ async createUserGroup(createUserGroupDto: CreateUserGroupDto) { const users = await this.userRepository.find({ where: { id: In(createUserGroupDto.userIds) } }); diff --git a/src/modules/users/users.module.ts b/src/modules/users/users.module.ts index 8e439a9..67bbb5b 100644 --- a/src/modules/users/users.module.ts +++ b/src/modules/users/users.module.ts @@ -17,7 +17,7 @@ import { UserSettingsRepository } from "../settings/repositories/user-settings.r imports: [TypeOrmModule.forFeature([User, Role, UserGroup, UserSetting])], providers: [UsersService, UserRepository, RoleRepository, UserGroupRepository, UserSettingsRepository, UserSettingsService], controllers: [UsersController], - exports: [UsersService, TypeOrmModule], + exports: [UsersService], }) export class UsersModule implements OnApplicationBootstrap { // constructor(private userService: UsersService) {} diff --git a/src/modules/wallets/DTO/deposit-wallet.dto.ts b/src/modules/wallets/DTO/deposit-wallet.dto.ts new file mode 100644 index 0000000..515e30f --- /dev/null +++ b/src/modules/wallets/DTO/deposit-wallet.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsEnum, IsInt, IsNotEmpty, IsUUID, IsUrl, Min } from "class-validator"; + +import { WalletMessage } from "../../../common/enums/message.enum"; +import { GatewayEnum } from "../../payments/enums/gateway.enum"; + +export class DepositDto { + @IsNotEmpty({ message: WalletMessage.AMOUNT_REQUIRED }) + @IsInt({ message: WalletMessage.AMOUNT_MUST_BE_INTEGER }) + @Min(100_000, { message: WalletMessage.AMOUNT_MINIMUM }) + @ApiProperty({ description: "Amount to charge", example: 100_000 }) + amount: number; +} + +export class GatewayDepositDto extends DepositDto { + @IsNotEmpty({ message: WalletMessage.GATEWAY_REQUIRED }) + @IsEnum(GatewayEnum) + @ApiProperty({ description: "Gateway to use", example: "zarinpal" }) + gateway: GatewayEnum; +} + +export class CardToCardDepositDto extends DepositDto { + @IsNotEmpty({ message: WalletMessage.RECEIPT_URL_REQUIRED }) + @IsUrl({ protocols: ["http", "https"], require_protocol: true }, { message: WalletMessage.RECEIPT_URL_SHOULD_BE_URL }) + @ApiProperty({ description: "the " }) + transferReceiptUrl: string; + + @IsNotEmpty({ message: WalletMessage.BANK_ACCOUNT_ID_REQUIRED }) + @IsUUID("4", { message: WalletMessage.BANK_ACCOUNT_ID_SHOULD_BE_UUID }) + bankAccountId: string; +} + +// @IsNotEmpty({ message: WalletMessage.DEPOSIT_TYPE_REQUIRED }) +// @IsEnum(DepositType) +// @ApiProperty({ description: "Deposit type", example: DepositType.GATEWAY }) +// depositType: DepositType; diff --git a/src/modules/wallets/constant/index.ts b/src/modules/wallets/constant/index.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/modules/wallets/entities/transaction.entity.ts b/src/modules/wallets/entities/transaction.entity.ts new file mode 100644 index 0000000..532c260 --- /dev/null +++ b/src/modules/wallets/entities/transaction.entity.ts @@ -0,0 +1,20 @@ +import { Column, Entity, ManyToOne } from "typeorm"; + +import { Wallet } from "./wallet.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; +import { TransactionType } from "../enums/transaction-type.enum"; + +@Entity() +export class WalletTransaction extends BaseEntity { + @ManyToOne(() => Wallet, (wallet) => wallet.transactions, { onDelete: "CASCADE", nullable: false }) + wallet: Wallet; + + @Column({ type: "decimal", precision: 10, scale: 2, nullable: false }) + amount: number; + + @Column({ type: "enum", enum: TransactionType, nullable: false }) + type: TransactionType; + + @Column({ type: "text", nullable: false }) + description: string; +} diff --git a/src/modules/wallets/entities/wallet.entity.ts b/src/modules/wallets/entities/wallet.entity.ts new file mode 100644 index 0000000..f8d783b --- /dev/null +++ b/src/modules/wallets/entities/wallet.entity.ts @@ -0,0 +1,18 @@ +import { Column, Entity, JoinColumn, OneToMany, OneToOne } from "typeorm"; + +import { WalletTransaction } from "./transaction.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; +import { User } from "../../users/entities/user.entity"; + +@Entity() +export class Wallet extends BaseEntity { + @OneToOne(() => User, (user) => user.wallet, { onDelete: "CASCADE" }) + @JoinColumn() + user: User; + + @Column({ type: "decimal", precision: 10, scale: 2, default: 0 }) + balance: number; + + @OneToMany(() => WalletTransaction, (transaction) => transaction.wallet) + transactions: WalletTransaction[]; +} diff --git a/src/modules/wallets/enums/transaction-type.enum.ts b/src/modules/wallets/enums/transaction-type.enum.ts new file mode 100644 index 0000000..5b6b66a --- /dev/null +++ b/src/modules/wallets/enums/transaction-type.enum.ts @@ -0,0 +1,4 @@ +export enum TransactionType { + DEPOSIT = "DEPOSIT", + WITHDRAWAL = "WITHDRAWAL", +} diff --git a/src/modules/wallets/providers/wallets.service.ts b/src/modules/wallets/providers/wallets.service.ts new file mode 100644 index 0000000..335e78f --- /dev/null +++ b/src/modules/wallets/providers/wallets.service.ts @@ -0,0 +1,57 @@ +import { BadRequestException, HttpException, Injectable, InternalServerErrorException } from "@nestjs/common"; +import { DataSource } from "typeorm"; + +import { WalletMessage } from "../../../common/enums/message.enum"; +import { PaymentsService } from "../../payments/providers/payments.service"; +import { GatewayDepositDto } from "../DTO/deposit-wallet.dto"; +import { Wallet } from "../entities/wallet.entity"; +import { WalletsRepository } from "../repositories/wallets.repository"; + +@Injectable() +export class WalletsService { + constructor( + private readonly walletsRepository: WalletsRepository, + private readonly paymentsService: PaymentsService, + private readonly dataSource: DataSource, + ) {} + + //*********************************** */ + async getBalance(userId: string) { + const wallet = await this.walletsRepository.findOneBy({ user: { id: userId } }); + if (!wallet) throw new BadRequestException(WalletMessage.WALLET_NOT_FOUND); + + return { + balance: wallet.balance, + }; + } + //*********************************** */ + async chargeWalletWithGateway(chargeDto: GatewayDepositDto, userId: string) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + try { + const { amount, gateway } = chargeDto; + const wallet = await queryRunner.manager.findOne(Wallet, { where: { user: { id: userId } }, relations: ["user"] }); + if (!wallet) throw new BadRequestException(WalletMessage.WALLET_NOT_FOUND); + + const gatewayData = await this.paymentsService.processPayment( + gateway, + amount, + WalletMessage.DEPOSIT_WALLET_IPG, + wallet.user.email, + wallet.user.phone, + ); + + const payment = await this.paymentsService.createPaymentForUser(wallet.user, amount, gatewayData.reference, queryRunner); + return { + payment, + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + if (error instanceof HttpException) throw error; + throw new InternalServerErrorException(WalletMessage.ERROR_IN_CHARGE_WALLET); + } finally { + await queryRunner.release(); + } + } +} diff --git a/src/modules/wallets/repositories/wallet-transactions.repository.ts b/src/modules/wallets/repositories/wallet-transactions.repository.ts new file mode 100644 index 0000000..3cde1b5 --- /dev/null +++ b/src/modules/wallets/repositories/wallet-transactions.repository.ts @@ -0,0 +1,12 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { WalletTransaction } from "../entities/transaction.entity"; + +@Injectable() +export class WalletTransactionsRepository extends Repository { + constructor(@InjectRepository(WalletTransaction) walletTransactionsRepository: Repository) { + super(walletTransactionsRepository.target, walletTransactionsRepository.manager, walletTransactionsRepository.queryRunner); + } +} diff --git a/src/modules/wallets/repositories/wallets.repository.ts b/src/modules/wallets/repositories/wallets.repository.ts new file mode 100644 index 0000000..3e6149a --- /dev/null +++ b/src/modules/wallets/repositories/wallets.repository.ts @@ -0,0 +1,12 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { Wallet } from "../entities/wallet.entity"; + +@Injectable() +export class WalletsRepository extends Repository { + constructor(@InjectRepository(Wallet) walletsRepository: Repository) { + super(walletsRepository.target, walletsRepository.manager, walletsRepository.queryRunner); + } +} diff --git a/src/modules/wallets/wallets.controller.ts b/src/modules/wallets/wallets.controller.ts new file mode 100644 index 0000000..b42901e --- /dev/null +++ b/src/modules/wallets/wallets.controller.ts @@ -0,0 +1,31 @@ +import { Body, Controller, Get, Post } from "@nestjs/common"; +import { ApiOperation, ApiTags } from "@nestjs/swagger"; + +import { GatewayDepositDto } from "./DTO/deposit-wallet.dto"; +import { WalletsService } from "./providers/wallets.service"; +import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; +import { Roles } from "../../common/decorators/roles.decorator"; +import { UserDec } from "../../common/decorators/user.decorator"; +import { RoleEnum } from "../users/enums/role.enum"; + +@Controller("wallets") +@ApiTags("wallets") +export class WalletsController { + constructor(private readonly walletsService: WalletsService) {} + + @ApiOperation({ summary: "Get balance ==> user route" }) + @AuthGuards() + @Roles(RoleEnum.USER) + @Get("balance") + getBalance(@UserDec("id") userId: string) { + return this.walletsService.getBalance(userId); + } + + @ApiOperation({ summary: "Charge wallet ==> user route" }) + @AuthGuards() + @Roles(RoleEnum.USER) + @Post("deposit/gateway") + chargeWalletWithGateway(@Body() chargeDto: GatewayDepositDto, @UserDec("id") userId: string) { + return this.walletsService.chargeWalletWithGateway(chargeDto, userId); + } +} diff --git a/src/modules/wallets/wallets.module.ts b/src/modules/wallets/wallets.module.ts new file mode 100644 index 0000000..98466b0 --- /dev/null +++ b/src/modules/wallets/wallets.module.ts @@ -0,0 +1,18 @@ +import { Module } from "@nestjs/common"; +import { TypeOrmModule } from "@nestjs/typeorm"; + +import { WalletTransaction } from "./entities/transaction.entity"; +import { Wallet } from "./entities/wallet.entity"; +import { WalletsService } from "./providers/wallets.service"; +import { WalletTransactionsRepository } from "./repositories/wallet-transactions.repository"; +import { WalletsRepository } from "./repositories/wallets.repository"; +import { WalletsController } from "./wallets.controller"; +import { PaymentsModule } from "../payments/payments.module"; + +@Module({ + imports: [TypeOrmModule.forFeature([Wallet, WalletTransaction]), PaymentsModule], + providers: [WalletsService, WalletsRepository, WalletTransactionsRepository], + controllers: [WalletsController], + exports: [WalletsService], +}) +export class WalletsModule {}