diff --git a/.env.example b/.env.example index c8b6f2c..460bbbe 100644 --- a/.env.example +++ b/.env.example @@ -46,3 +46,10 @@ BUCKET_ACCESS_KEY= BUCKET_SECRET_KEY= BUCKET_UPLOAD_URL=https://dsc.storage.iran.liara.space + + +IPG_TYPE=sandbox +ZARINPAL_MERCHANT_ID=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx +SITE_URL=http://localhost:4000/payment +ZARINPAL_API_URL=zarinpal.com/pg +CALLBACK_URL=http://localhost:4000/payments/verify \ No newline at end of file diff --git a/database/seeder.runner.ts b/database/seeder.runner.ts index a462d01..ccc6581 100644 --- a/database/seeder.runner.ts +++ b/database/seeder.runner.ts @@ -1,18 +1,20 @@ import { Logger } from "@nestjs/common"; import { connectionSource } from "./connection"; -import { seedAdmin } from "./seeders/admin.seeder"; -import { seedNotifSettings } from "./seeders/notif-setting.seeder"; -import { seedRole } from "./seeders/role.seeder"; +// import { seedAdmin } from "./seeders/admin.seeder"; +// import { seedNotifSettings } from "./seeders/notif-setting.seeder"; +import { seedPaymentGateways } from "./seeders/payment-gateway.seeder"; +// import { seedRole } from "./seeders/role.seeder"; const logger = new Logger("Seeder"); export const runSeeder = async () => { await connectionSource.initialize(); logger.log("start seeding database"); - await seedRole(connectionSource, logger); - await seedAdmin(connectionSource, logger); - await seedNotifSettings(connectionSource, logger); + // await seedRole(connectionSource, logger); + // await seedAdmin(connectionSource, logger); + // await seedNotifSettings(connectionSource, logger); + await seedPaymentGateways(connectionSource, logger); logger.log("seeding completed"); process.exit(0); diff --git a/database/seeders/payment-gateway.seeder.ts b/database/seeders/payment-gateway.seeder.ts new file mode 100644 index 0000000..10763ae --- /dev/null +++ b/database/seeders/payment-gateway.seeder.ts @@ -0,0 +1,29 @@ +import { Logger } from "@nestjs/common"; +import { DataSource } from "typeorm"; + +import { PaymentGateway } from "../../src/modules/payments/entities/payment-gateway.entity"; +import { GatewayEnum } from "../../src/modules/payments/enums/gateway.enum"; + +const paymentGateways: Partial[] = [ + { + name: GatewayEnum.ZARINPAL, + nameFa: "زرین پال", + logoUrl: "https://www.zarinpal.com/docs/logo/fa-dark.svg", + description: "درگاه پرداخت زرین پال", + }, +]; + +export const seedPaymentGateways = async (dataSource: DataSource, logger: Logger) => { + try { + const paymentGatewayRepo = dataSource.getRepository(PaymentGateway); + + for (const paymentGateway of paymentGateways) { + const pg = paymentGatewayRepo.create(paymentGateway); + await paymentGatewayRepo.save(pg); + } + logger.log("pg seeded successfully"); + } catch (error) { + logger.error("error in seeding pg", error); + process.exit(1); + } +}; diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index 8efe13d..5a793e2 100644 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -196,6 +196,9 @@ export const enum WalletMessage { BANK_ACCOUNT_ID_SHOULD_BE_UUID = "شناسه حساب بانکی باید یک UUID معتبر باشد", ERROR_IN_CHARGE_WALLET = "خطا در شارژ کیف پول", DEPOSIT_WALLET_IPG = "شارژ کیف پول از طریق درگاه", + TRANSFER_METHOD_REQUIRED = "روش انتفال اجباری است", + GATEWAY_ID_SHOULD_BE_UUID = "آیدی درگاه پرداخت باید UUID باشد", + // DEPOSIT_WITH_TRANSFER = "DEPOSIT_WITH_TRANSFER", } export const enum PaymentMessage { @@ -203,6 +206,25 @@ export const enum PaymentMessage { ERROR_IN_PROCESS_PAYMENT = "خطا در پردازش پرداخت", ERROR_IN_VERIFY_PAYMENT = "خطا در تایید پرداخت", PAYMENT_NOT_FOUND_WITH_REF = "پرداختی با این شناسه یافت نشد", + CARD_NUMBER_REQUIRED = "شماره کارت الزامی است", + CARD_NUMBER_NUMBER_STRING = "شماره کارت باید شامل اعداد باشد", + CARD_NUMBER_LENGTH = "شماره کارت باید ۱۶ رقم باشد", + IBAN_REQUIRED = "شماره شبا الزامی است", + IBAN_NUMBER_STRING = "شماره شبا باید شامل اعداد باشد", + IBAN_INVALID = "شماره شبا معتبر نیست", + BANK_NAME_REQUIRED = "نام بانک الزامی است", + BANK_NAME_STRING = "نام بانک باید شامل حروف باشد", + BANK_NAME_LENGTH = "نام بانک باید بین ۲ تا ۱۰۰ کاراکتر باشد", + ACCOUNT_OWNER_NAME_REQUIRED = "نام صاحب حساب الزامی است", + ACCOUNT_OWNER_NAME_STRING = "نام صاحب حساب باید شامل حروف باشد", + ACCOUNT_OWNER_NAME_LENGTH = "نام صاحب حساب باید بین ۳ تا ۱۵۰ کاراکتر باشد", + IS_ACTIVE_REQUIRED = "وضعیت فعال بودن حساب الزامی است", + IS_ACTIVE_BOOLEAN = "وضعیت فعال بودن حساب باید true یا false باشد", + CARD_NUMBER_EXIST = "شماره کارت تکراری می‌باشد", + IBAN_EXIST = "شماره شبا تکراری می‌باشد", + BANK_ACCOUNT_NOT_FOUND = "حساب بانکی پیدا نشد", + DEPOSIT_CREATED = "درخواست واریز ثبت شد", + VALIDATED_BEFORE = "این پرداخت قبلا تایید شده است", } export const enum SettingMessageEnum { diff --git a/src/modules/payments/DTO/create-bankaccount.dto.ts b/src/modules/payments/DTO/create-bankaccount.dto.ts new file mode 100644 index 0000000..3489628 --- /dev/null +++ b/src/modules/payments/DTO/create-bankaccount.dto.ts @@ -0,0 +1,36 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsBoolean, IsNotEmpty, IsNumberString, IsString, Length } from "class-validator"; + +import { PaymentMessage } from "../../../common/enums/message.enum"; + +export class CreateBankAccountDto { + @IsNotEmpty({ message: PaymentMessage.CARD_NUMBER_REQUIRED }) + @IsNumberString({ no_symbols: true }, { message: PaymentMessage.CARD_NUMBER_NUMBER_STRING }) + @Length(16, 16, { message: PaymentMessage.CARD_NUMBER_LENGTH }) + @ApiProperty({ description: "card number", example: "1234567812345678" }) + cardNumber: string; + + @IsNotEmpty({ message: PaymentMessage.IBAN_REQUIRED }) + @IsNumberString({ no_symbols: true }, { message: PaymentMessage.IBAN_NUMBER_STRING }) + // @IsIBAN({ message: PaymentMessage.IBAN_INVALID }) + @Length(24, 24, { message: PaymentMessage.IBAN_INVALID }) + @ApiProperty({ description: "IBAN number", example: "123456789012345678901234" }) + IBan: string; + + @IsNotEmpty({ message: PaymentMessage.BANK_NAME_REQUIRED }) + @IsString({ message: PaymentMessage.BANK_NAME_STRING }) + @Length(2, 100, { message: PaymentMessage.BANK_NAME_LENGTH }) + @ApiProperty({ description: "bank name", example: "Example Bank" }) + bankName: string; + + @IsNotEmpty({ message: PaymentMessage.ACCOUNT_OWNER_NAME_REQUIRED }) + @IsString({ message: PaymentMessage.ACCOUNT_OWNER_NAME_STRING }) + @Length(3, 150, { message: PaymentMessage.ACCOUNT_OWNER_NAME_LENGTH }) + @ApiProperty({ description: "account owner name", example: "John Doe" }) + accountOwnerName: string; + + @IsNotEmpty({ message: PaymentMessage.IS_ACTIVE_REQUIRED }) + @IsBoolean({ message: PaymentMessage.IS_ACTIVE_BOOLEAN }) + @ApiProperty({ description: "specify that bank account is active", example: true }) + isActive: boolean; +} diff --git a/src/modules/payments/DTO/deposit-wallet.dto.ts b/src/modules/payments/DTO/deposit-wallet.dto.ts index 070a37b..9350240 100644 --- a/src/modules/payments/DTO/deposit-wallet.dto.ts +++ b/src/modules/payments/DTO/deposit-wallet.dto.ts @@ -2,7 +2,7 @@ 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 "../enums/gateway.enum"; +import { TransferMethod } from "../enums/payment-type.enum"; export class DepositDto { @IsNotEmpty({ message: WalletMessage.AMOUNT_REQUIRED }) @@ -13,24 +13,30 @@ export class DepositDto { } export class GatewayDepositDto extends DepositDto { + // @IsNotEmpty({ message: WalletMessage.GATEWAY_REQUIRED }) + // @IsEnum(GatewayEnum) + // @ApiProperty({ description: "Gateway to use", example: "zarinpal" }) + // gateway: GatewayEnum; + @IsNotEmpty({ message: WalletMessage.GATEWAY_REQUIRED }) - @IsEnum(GatewayEnum) - @ApiProperty({ description: "Gateway to use", example: "zarinpal" }) - gateway: GatewayEnum; + @IsUUID("4", { message: WalletMessage.GATEWAY_ID_SHOULD_BE_UUID }) + @ApiProperty({ description: "Gateway id to pay", example: "e6fdce2a-9f91-47c4-8561-48368fc275b5" }) + gatewayId: string; } -export class CardToCardDepositDto extends DepositDto { +export class TransferDepositDto 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 " }) + @ApiProperty({ description: "the transferReceipt url", example: "https://example.com/image.png" }) transferReceiptUrl: string; @IsNotEmpty({ message: WalletMessage.BANK_ACCOUNT_ID_REQUIRED }) @IsUUID("4", { message: WalletMessage.BANK_ACCOUNT_ID_SHOULD_BE_UUID }) + @ApiProperty({ description: "bank account id", example: "e6fdce2a-9f91-47c4-8561-48368fc275b5" }) bankAccountId: string; -} -// @IsNotEmpty({ message: WalletMessage.DEPOSIT_TYPE_REQUIRED }) -// @IsEnum(DepositType) -// @ApiProperty({ description: "Deposit type", example: DepositType.GATEWAY }) -// depositType: DepositType; + @IsNotEmpty({ message: WalletMessage.TRANSFER_METHOD_REQUIRED }) + @IsEnum(TransferMethod) + @ApiProperty({ description: "transfer method", example: TransferMethod.CARD_TO_CARD, enum: TransferMethod }) + method: TransferMethod; +} diff --git a/src/modules/payments/DTO/search-transaction-query.dto.ts b/src/modules/payments/DTO/search-transaction-query.dto.ts new file mode 100644 index 0000000..dd7a7de --- /dev/null +++ b/src/modules/payments/DTO/search-transaction-query.dto.ts @@ -0,0 +1,27 @@ +import { ApiPropertyOptional } from "@nestjs/swagger"; +import { IsDateString, IsEnum, IsOptional, IsString } from "class-validator"; + +import { PaginationDto } from "../../../common/DTO/pagination.dto"; +import { PaymentStatus } from "../enums/payment-status.enum"; + +export class SearchTransactionQueryDto extends PaginationDto { + @IsOptional() + @IsString() + @ApiPropertyOptional({ description: "user name or family or ...", example: "mamad" }) + user?: string; + + @IsOptional() + @IsDateString() + @ApiPropertyOptional({ description: "the date string", example: "2024-01-25" }) + date?: string; + + @IsOptional() + @IsEnum(PaymentStatus) + @ApiPropertyOptional({ enum: PaymentStatus, description: "payment status", example: PaymentStatus.PENDING }) + status?: PaymentStatus; + + @IsOptional() + @IsString() + @ApiPropertyOptional({ description: "search quey", example: "harchi" }) + q?: string; +} diff --git a/src/modules/payments/DTO/update-bankaccount.dto.ts b/src/modules/payments/DTO/update-bankaccount.dto.ts new file mode 100644 index 0000000..ff7c603 --- /dev/null +++ b/src/modules/payments/DTO/update-bankaccount.dto.ts @@ -0,0 +1,5 @@ +import { PartialType } from "@nestjs/swagger"; + +import { CreateBankAccountDto } from "./create-bankaccount.dto"; + +export class UpdateBankAccountDto extends PartialType(CreateBankAccountDto) {} diff --git a/src/modules/payments/entities/bank-account.entity.ts b/src/modules/payments/entities/bank-account.entity.ts index a892899..6f3329e 100644 --- a/src/modules/payments/entities/bank-account.entity.ts +++ b/src/modules/payments/entities/bank-account.entity.ts @@ -1,14 +1,15 @@ -import { Column, Entity } from "typeorm"; +import { Column, DeleteDateColumn, Entity, OneToMany } from "typeorm"; +import { DepositRequest } from "./deposit-request.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; @Entity() export class BankAccount extends BaseEntity { @Column({ type: "varchar", length: 100, unique: true, nullable: true }) - cardNumber: string; + cardNumber?: string; @Column({ type: "varchar", length: 100, unique: true, nullable: true }) - shebaNumber: string; + IBan?: string; @Column({ type: "varchar", length: 100, nullable: false }) bankName: string; @@ -18,4 +19,10 @@ export class BankAccount extends BaseEntity { @Column({ default: true }) isActive: boolean; + + @OneToMany(() => DepositRequest, (depositRequest) => depositRequest.bankAccount) + depositRequests: DepositRequest[]; + + @DeleteDateColumn() + deletedAt: Date; } diff --git a/src/modules/payments/entities/deposit-request.entity.ts b/src/modules/payments/entities/deposit-request.entity.ts new file mode 100644 index 0000000..3ac4734 --- /dev/null +++ b/src/modules/payments/entities/deposit-request.entity.ts @@ -0,0 +1,17 @@ +import { Column, Entity, ManyToOne } from "typeorm"; + +import { BankAccount } from "./bank-account.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; +import { User } from "../../users/entities/user.entity"; + +@Entity() +export class DepositRequest extends BaseEntity { + @ManyToOne(() => BankAccount, (bankAccount) => bankAccount.depositRequests, { nullable: false, onDelete: "RESTRICT" }) + bankAccount: BankAccount; + + @ManyToOne(() => User, { nullable: false, onDelete: "RESTRICT" }) + user: User; + + @Column({ type: "varchar", length: 250, nullable: false }) + receiptUrl: string; +} diff --git a/src/modules/payments/entities/payment-gateway.entity.ts b/src/modules/payments/entities/payment-gateway.entity.ts new file mode 100644 index 0000000..50e486c --- /dev/null +++ b/src/modules/payments/entities/payment-gateway.entity.ts @@ -0,0 +1,22 @@ +import { Column, Entity } from "typeorm"; + +import { BaseEntity } from "../../../common/entities/base.entity"; +import { GatewayEnum } from "../enums/gateway.enum"; + +@Entity() +export class PaymentGateway extends BaseEntity { + @Column({ type: "enum", enum: GatewayEnum, nullable: false }) + name: GatewayEnum; + + @Column({ type: "varchar", length: 150, nullable: false }) + nameFa: string; + + @Column({ type: "varchar", length: 150, nullable: false }) + logoUrl: string; + + @Column({ type: "boolean", default: true }) + isActive: boolean; + + @Column({ type: "text", nullable: true }) + description: string; +} diff --git a/src/modules/payments/entities/payment-method.entity.ts b/src/modules/payments/entities/payment-method.entity.ts deleted file mode 100644 index a3da052..0000000 --- a/src/modules/payments/entities/payment-method.entity.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { Column, Entity, ManyToOne } from "typeorm"; - -import { BankAccount } from "./bank-account.entity"; -import { BaseEntity } from "../../../common/entities/base.entity"; - -@Entity() -export class PaymentMethod extends BaseEntity { - @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; -} diff --git a/src/modules/payments/entities/payment.entity.ts b/src/modules/payments/entities/payment.entity.ts index 4bd7da0..226a4a0 100644 --- a/src/modules/payments/entities/payment.entity.ts +++ b/src/modules/payments/entities/payment.entity.ts @@ -2,7 +2,8 @@ import Decimal from "decimal.js"; import { Column, Entity, ManyToOne } from "typeorm"; -import { PaymentMethod } from "./payment-method.entity"; +import { BankAccount } from "./bank-account.entity"; +import { PaymentGateway } from "./payment-gateway.entity"; import { BaseEntity } from "../../../common/entities/base.entity"; import { DecimalTransformer } from "../../../common/transformers/decimal.transformer"; import { User } from "../../users/entities/user.entity"; @@ -29,6 +30,9 @@ export class Payment extends BaseEntity { @ManyToOne(() => User, (user) => user.payments, { nullable: false, onDelete: "RESTRICT" }) user: User; - @ManyToOne(() => PaymentMethod, { nullable: false }) - paymentMethod: PaymentMethod; + @ManyToOne(() => PaymentGateway, { nullable: true, onDelete: "SET NULL" }) + paymentGateway?: PaymentGateway; + + @ManyToOne(() => BankAccount, { nullable: true, onDelete: "SET NULL" }) + bankAccount?: BankAccount; // for CARD_TO_CARD or SHEBA transfers } diff --git a/src/modules/payments/enums/payment-type.enum.ts b/src/modules/payments/enums/payment-type.enum.ts index 6f0230c..97cf002 100644 --- a/src/modules/payments/enums/payment-type.enum.ts +++ b/src/modules/payments/enums/payment-type.enum.ts @@ -3,3 +3,8 @@ export enum PaymentType { GATEWAY = "GATEWAY", SHEBA = "SHEBA", } + +export enum TransferMethod { + CARD_TO_CARD = "CARD_TO_CARD", + SHEBA = "SHEBA", +} diff --git a/src/modules/payments/gateways/zarinpal.gateway.ts b/src/modules/payments/gateways/zarinpal.gateway.ts index 1db1743..97b6532 100644 --- a/src/modules/payments/gateways/zarinpal.gateway.ts +++ b/src/modules/payments/gateways/zarinpal.gateway.ts @@ -52,7 +52,6 @@ export class ZarinpalGateway implements IPaymentGateway { }), ), ); - return { redirectUrl: `${this.gatewayApiUrl}/StartPay/${data.data.authority}`, message: data.data.message, diff --git a/src/modules/payments/payments.controller.ts b/src/modules/payments/payments.controller.ts index f54b318..62000ac 100644 --- a/src/modules/payments/payments.controller.ts +++ b/src/modules/payments/payments.controller.ts @@ -1,12 +1,18 @@ -import { Body, Controller, Get, Param, Post, Query } from "@nestjs/common"; +import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; -import { GatewayDepositDto } from "./DTO/deposit-wallet.dto"; +import { CreateBankAccountDto } from "./DTO/create-bankaccount.dto"; +import { GatewayDepositDto, TransferDepositDto } from "./DTO/deposit-wallet.dto"; +import { SearchTransactionQueryDto } from "./DTO/search-transaction-query.dto"; +import { UpdateBankAccountDto } from "./DTO/update-bankaccount.dto"; import { VerifyParamDto, VerifyQueryDto } from "./DTO/verify-payment.dto"; import { PaymentsService } from "./providers/payments.service"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; +import { Pagination } from "../../common/decorators/pagination.decorator"; import { Roles } from "../../common/decorators/roles.decorator"; import { UserDec } from "../../common/decorators/user.decorator"; +import { ParamDto } from "../../common/DTO/param.dto"; +import { Role } from "../users/entities/role.entity"; import { RoleEnum } from "../users/enums/role.enum"; @Controller("payments") @@ -29,8 +35,67 @@ export class PaymentsController { return this.paymentsService.chargeWalletWithGateway(chargeDto, userId); } + @ApiOperation({ summary: "charge wallet with transfer ==> user route" }) + @AuthGuards() + @Roles(RoleEnum.USER) + @Post("deposit/transfer") + chargeWalletWithTransfer(@Body() depositDto: TransferDepositDto, @UserDec("id") userId: string) { + return this.paymentsService.chargeWalletWithTransfer(depositDto, userId); + } + + @ApiOperation({ summary: "get deposit request of users" }) + @AuthGuards() + @Roles(RoleEnum.ADMIN) + @Get("deposit/transfer") + getDepositRequests() {} + + @ApiOperation({ summary: "get transaction for admin" }) + @AuthGuards() + @Pagination() + @Roles(RoleEnum.ADMIN) + @Get("transactions") + getTransaction(@Query() queryDto: SearchTransactionQueryDto) { + return this.paymentsService.getTransactions(queryDto); + } + @Get("verify/:gateway") verifyPayment(@Param() paramDto: VerifyParamDto, @Query() queryDto: VerifyQueryDto) { return this.paymentsService.verifyPayment(paramDto.gateway, queryDto); } + + ///------------------- bank account ----------------------------- + + @AuthGuards() + @Roles(RoleEnum.ADMIN) + @ApiOperation({ summary: "add bank account for transfer and deposit ==> admin route" }) + @Post("bank-account") + addBankAccount(@Body() createDto: CreateBankAccountDto) { + return this.paymentsService.addBankAccount(createDto); + } + + @AuthGuards() + @Roles(RoleEnum.ADMIN, RoleEnum.USER) + @ApiOperation({ summary: "get bank account by id" }) + @Get("bank-account/:id") + getBankAccountById(@Param() paramDto: ParamDto) { + return this.paymentsService.getBankAccountById(paramDto.id); + } + + @AuthGuards() + @Roles(RoleEnum.ADMIN) + @ApiOperation({ summary: "update bank account with its id" }) + @Patch("bank-account/:id") + updateBankAccount(@Body() updateDto: UpdateBankAccountDto, @Param() paramDto: ParamDto) { + return this.paymentsService.updateBankAccount(updateDto, paramDto.id); + } + + @AuthGuards() + @Roles(RoleEnum.ADMIN, RoleEnum.USER) + @ApiOperation({ summary: "get bank account" }) + @Get("bank-account") + getBankAccounts(@UserDec("role") userRole: Role) { + return this.paymentsService.getBankAccounts(userRole); + } + + ///------------------- Deposit transfer ----------------------------- } diff --git a/src/modules/payments/payments.module.ts b/src/modules/payments/payments.module.ts index 6df0aa0..ac13b57 100644 --- a/src/modules/payments/payments.module.ts +++ b/src/modules/payments/payments.module.ts @@ -4,7 +4,6 @@ import { TypeOrmModule } from "@nestjs/typeorm"; import { PAYMENT, 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"; @@ -15,10 +14,14 @@ import { BankAccountsRepository } from "./repositories/bank-accounts.repository" import { PaymentsRepository } from "./repositories/payments.repository"; import { zarinpalConfig } from "../../configs/zarinpal.config"; import { WalletsModule } from "../wallets/wallets.module"; +import { DepositRequest } from "./entities/deposit-request.entity"; +import { PaymentGateway } from "./entities/payment-gateway.entity"; +import { DepositRequestsRepository } from "./repositories/deposit-requests.repository"; +import { PaymentGatewaysRepository } from "./repositories/payment-gateway.repository"; @Module({ imports: [ - TypeOrmModule.forFeature([Payment, PaymentMethod, BankAccount]), + TypeOrmModule.forFeature([Payment, PaymentGateway, BankAccount, DepositRequest]), BullModule.registerQueue({ name: PAYMENT.PAYMENT_QUEUE_NAME }), WalletsModule, ], @@ -29,6 +32,8 @@ import { WalletsModule } from "../wallets/wallets.module"; ZarinpalGateway, PaymentsRepository, BankAccountsRepository, + PaymentGatewaysRepository, + DepositRequestsRepository, PaymentProcessor, { provide: ZARINPAL_CONFIG, diff --git a/src/modules/payments/providers/payments.service.ts b/src/modules/payments/providers/payments.service.ts index d0ae9cd..0d10bf8 100644 --- a/src/modules/payments/providers/payments.service.ts +++ b/src/modules/payments/providers/payments.service.ts @@ -1,22 +1,32 @@ +import { randomUUID } from "node:crypto"; + import { InjectQueue } from "@nestjs/bullmq"; import { BadRequestException, HttpException, Injectable, InternalServerErrorException } from "@nestjs/common"; import { Queue } from "bullmq"; // eslint-disable-next-line import/no-named-as-default import Decimal from "decimal.js"; -import { DataSource, QueryRunner } from "typeorm"; +import { DataSource, Not, QueryRunner } from "typeorm"; -import { PaymentMessage, WalletMessage } from "../../../common/enums/message.enum"; +import { CommonMessage, PaymentMessage, UserMessage, WalletMessage } from "../../../common/enums/message.enum"; +import { Role } from "../../users/entities/role.entity"; import { User } from "../../users/entities/user.entity"; -import { Wallet } from "../../wallets/entities/wallet.entity"; +import { RoleEnum } from "../../users/enums/role.enum"; import { WalletsService } from "../../wallets/providers/wallets.service"; import { PAYMENT } from "../constants"; -import { GatewayDepositDto } from "../DTO/deposit-wallet.dto"; +import { CreateBankAccountDto } from "../DTO/create-bankaccount.dto"; +import { GatewayDepositDto, TransferDepositDto } from "../DTO/deposit-wallet.dto"; +import { SearchTransactionQueryDto } from "../DTO/search-transaction-query.dto"; +import { UpdateBankAccountDto } from "../DTO/update-bankaccount.dto"; import { VerifyQueryDto } from "../DTO/verify-payment.dto"; -import { PaymentMethod } from "../entities/payment-method.entity"; +import { BankAccount } from "../entities/bank-account.entity"; +import { DepositRequest } from "../entities/deposit-request.entity"; +import { PaymentGateway } from "../entities/payment-gateway.entity"; import { Payment } from "../entities/payment.entity"; import { PaymentStatus } from "../enums/payment-status.enum"; import { PaymentType } from "../enums/payment-type.enum"; import { PaymentGatewayFactory } from "../factories/payment.factory"; +import { BankAccountsRepository } from "../repositories/bank-accounts.repository"; +import { PaymentGatewaysRepository } from "../repositories/payment-gateway.repository"; import { PaymentsRepository } from "../repositories/payments.repository"; import { GatewayType } from "../types/gateway.type"; @@ -25,7 +35,9 @@ export class PaymentsService { constructor( @InjectQueue(PAYMENT.PAYMENT_QUEUE_NAME) private readonly paymentQueue: Queue, private readonly gatewayFactory: PaymentGatewayFactory, + private readonly paymentGatewaysRepository: PaymentGatewaysRepository, private readonly paymentsRepository: PaymentsRepository, + private readonly bankAccountsRepository: BankAccountsRepository, private readonly walletsService: WalletsService, private dataSource: DataSource, ) {} @@ -36,19 +48,17 @@ export class PaymentsService { 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 { amount, gatewayId } = chargeDto; - const gatewayData = await this.processPayment( - gateway, - amount, - WalletMessage.DEPOSIT_WALLET_IPG, - wallet.user.email, - wallet.user.phone, - ); + const user = await queryRunner.manager.findOneBy(User, { id: userId }); + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); - const payment = await this.createPaymentForUser(wallet.user, amount, gatewayData.reference, gateway, queryRunner); + const paymentGateway = await queryRunner.manager.findOneBy(PaymentGateway, { id: gatewayId }); + if (!paymentGateway) throw new BadRequestException(PaymentMessage.PAYMENT_GATEWAY_NOT_FOUND); + + const gatewayData = await this.processPayment(paymentGateway.name, amount, WalletMessage.DEPOSIT_WALLET_IPG, user.email, user.phone); + + const payment = await this.createGatewayPaymentForUser(user, amount, gatewayData.reference, paymentGateway.id, queryRunner); await queryRunner.commitTransaction(); return { @@ -63,6 +73,46 @@ export class PaymentsService { await queryRunner.release(); } } + + //*********************************** */ + //TODO:complete this + async chargeWalletWithTransfer(depositDto: TransferDepositDto, userId: string) { + const queryRunner = this.dataSource.createQueryRunner(); + await queryRunner.connect(); + await queryRunner.startTransaction(); + const { amount, bankAccountId, method, transferReceiptUrl } = depositDto; + try { + const bankAccount = await queryRunner.manager.findOneBy(BankAccount, { id: bankAccountId }); + if (!bankAccount) throw new BadRequestException(PaymentMessage.BANK_ACCOUNT_NOT_FOUND); + + const user = await queryRunner.manager.findOneBy(User, { id: userId }); + if (!user) throw new BadRequestException(UserMessage.USER_NOT_FOUND); + // + const reference = randomUUID(); + const paymentType = method as unknown as PaymentType; + + // + const payment = queryRunner.manager.create(Payment, { amount, reference, user, type: paymentType, bankAccount }); + + await queryRunner.manager.save(Payment, payment); + // + const depositRequest = queryRunner.manager.create(DepositRequest, { bankAccount, user, receiptUrl: transferReceiptUrl }); + + await queryRunner.manager.save(DepositRequest, depositRequest); + + await queryRunner.commitTransaction(); + return { + message: PaymentMessage.DEPOSIT_CREATED, + depositId: depositRequest.id, + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + + throw error; + } finally { + await queryRunner.release(); + } + } //*********************************** */ //*********************************** */ async processPayment(provider: GatewayType, amount: number, description: string, email?: string, mobile?: string) { @@ -85,11 +135,7 @@ export class PaymentsService { // lock: { mode: "pessimistic_write" }, }); if (!payment) throw new BadRequestException(PaymentMessage.PAYMENT_NOT_FOUND_WITH_REF); - if (payment.status === PaymentStatus.COMPLETED) { - return { - message: "completed before", - }; - } + if (payment.status === PaymentStatus.COMPLETED) throw new BadRequestException(PaymentMessage.VALIDATED_BEFORE); if (queryDto.Status !== "OK") { await this.handleFailedPayment(payment.id, queryRunner); @@ -135,20 +181,14 @@ export class PaymentsService { //*********************************** */ //*********************************** */ - async createPaymentForUser(user: User, amount: number, reference: string, gateway: GatewayType, queryRunner: QueryRunner) { - const paymentMethod = queryRunner.manager.create(PaymentMethod, { - gatewayName: gateway, - description: WalletMessage.DEPOSIT_WALLET_IPG, - }); - - await queryRunner.manager.save(PaymentMethod, paymentMethod); - + async createGatewayPaymentForUser(user: User, amount: number, reference: string, gatewayId: string, queryRunner: QueryRunner) { + // const payment = queryRunner.manager.create(Payment, { type: PaymentType.GATEWAY, amount, reference, user, - paymentMethod, + paymentGateway: { id: gatewayId }, }); await queryRunner.manager.save(Payment, payment); this.paymentQueue.add(PAYMENT.PAYMENT_START, { payment }, { delay: 1000 }); @@ -158,7 +198,8 @@ export class PaymentsService { //*********************************** */ //*********************************** */ async getAvailableGateways() { - return this.gatewayFactory.getAvailablePaymentGateways(); + const paymentGateways = await this.paymentGatewaysRepository.find({ where: { isActive: true } }); + return { paymentGateways }; } //*********************************** */ //*********************************** */ @@ -181,4 +222,69 @@ export class PaymentsService { const transaction = await this.walletsService.createPaymentTransaction(userId, amount, queryRunner); return transaction; } + + //*********************************** */ + async addBankAccount(createDto: CreateBankAccountDto) { + const existCardNumber = await this.bankAccountsRepository.findOneBy({ cardNumber: createDto.cardNumber }); + if (existCardNumber) throw new BadRequestException(PaymentMessage.CARD_NUMBER_EXIST); + // + const existIBan = await this.bankAccountsRepository.findOneBy({ IBan: createDto.IBan }); + if (existIBan) throw new BadRequestException(PaymentMessage.IBAN_EXIST); + // + const bankAccount = this.bankAccountsRepository.create({ ...createDto }); + await this.bankAccountsRepository.save(bankAccount); + + return { + message: CommonMessage.CREATED, + bankAccount, + }; + } + //*********************************** */ + async updateBankAccount(updateDto: UpdateBankAccountDto, bankAccountId: string) { + const bankAccount = await this.bankAccountsRepository.findOneBy({ id: bankAccountId }); + if (!bankAccount) throw new BadRequestException(PaymentMessage.BANK_ACCOUNT_NOT_FOUND); + + if (updateDto.cardNumber) { + const existCardNumber = await this.bankAccountsRepository.findOneBy({ cardNumber: updateDto.cardNumber, id: Not(bankAccountId) }); + if (existCardNumber) throw new BadRequestException(PaymentMessage.CARD_NUMBER_EXIST); + } + // + if (updateDto.IBan) { + const existIBan = await this.bankAccountsRepository.findOneBy({ IBan: updateDto.IBan, id: Not(bankAccountId) }); + if (existIBan) throw new BadRequestException(PaymentMessage.IBAN_EXIST); + } + + await this.bankAccountsRepository.save({ ...bankAccount, ...updateDto }); + } + //*********************************** */ + async getBankAccounts(role: Role) { + const userRole = role as unknown as RoleEnum; + let bankAccounts: BankAccount[]; + + if (userRole === RoleEnum.USER) { + bankAccounts = await this.bankAccountsRepository.find({ where: { isActive: true } }); + } else { + bankAccounts = await this.bankAccountsRepository.find(); + } + + return { + bankAccounts, + }; + } + + //*********************************** */ + async getBankAccountById(bankAccountId: string) { + const bankAccount = await this.bankAccountsRepository.findOneBy({ id: bankAccountId }); + if (!bankAccount) throw new BadRequestException(PaymentMessage.BANK_ACCOUNT_NOT_FOUND); + + return { + bankAccount, + }; + } + + // async deleteBankAccount() + //*********************************** */ + async getTransactions(queryDto: SearchTransactionQueryDto) { + return this.walletsService.getTransactionsForAdmin(queryDto); + } } diff --git a/src/modules/payments/repositories/deposit-requests.repository.ts b/src/modules/payments/repositories/deposit-requests.repository.ts new file mode 100644 index 0000000..13165f2 --- /dev/null +++ b/src/modules/payments/repositories/deposit-requests.repository.ts @@ -0,0 +1,12 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { DepositRequest } from "../entities/deposit-request.entity"; + +@Injectable() +export class DepositRequestsRepository extends Repository { + constructor(@InjectRepository(DepositRequest) depositRequestsRepository: Repository) { + super(depositRequestsRepository.target, depositRequestsRepository.manager, depositRequestsRepository.queryRunner); + } +} diff --git a/src/modules/payments/repositories/payment-gateway.repository.ts b/src/modules/payments/repositories/payment-gateway.repository.ts new file mode 100644 index 0000000..4987499 --- /dev/null +++ b/src/modules/payments/repositories/payment-gateway.repository.ts @@ -0,0 +1,12 @@ +import { Injectable } from "@nestjs/common"; +import { InjectRepository } from "@nestjs/typeorm"; +import { Repository } from "typeorm"; + +import { PaymentGateway } from "../entities/payment-gateway.entity"; + +@Injectable() +export class PaymentGatewaysRepository extends Repository { + constructor(@InjectRepository(PaymentGateway) paymentGatewaysRepository: Repository) { + super(paymentGatewaysRepository.target, paymentGatewaysRepository.manager, paymentGatewaysRepository.queryRunner); + } +} diff --git a/src/modules/payments/repositories/payment-methods.repository.ts b/src/modules/payments/repositories/payment-methods.repository.ts deleted file mode 100644 index 9b5f918..0000000 --- a/src/modules/payments/repositories/payment-methods.repository.ts +++ /dev/null @@ -1,11 +0,0 @@ -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/wallets/providers/wallets.service.ts b/src/modules/wallets/providers/wallets.service.ts index e51f4d8..1597762 100644 --- a/src/modules/wallets/providers/wallets.service.ts +++ b/src/modules/wallets/providers/wallets.service.ts @@ -3,15 +3,23 @@ import { BadRequestException, Injectable } from "@nestjs/common"; import Decimal from "decimal.js"; import { QueryRunner } from "typeorm"; +import { PaginationDto } from "../../../common/DTO/pagination.dto"; import { WalletMessage } from "../../../common/enums/message.enum"; +import { decimal } from "../../../common/transformers/decimal.transformer"; +import { SearchTransactionQueryDto } from "../../payments/DTO/search-transaction-query.dto"; +import { PaginationUtils } from "../../utils/providers/pagination.utils"; import { WalletTransaction } from "../entities/transaction.entity"; import { Wallet } from "../entities/wallet.entity"; import { TransactionType } from "../enums/transaction-type.enum"; +import { WalletTransactionsRepository } from "../repositories/wallet-transactions.repository"; import { WalletsRepository } from "../repositories/wallets.repository"; @Injectable() export class WalletsService { - constructor(private readonly walletsRepository: WalletsRepository) {} + constructor( + private readonly walletsRepository: WalletsRepository, + private readonly walletsTransactionRepo: WalletTransactionsRepository, + ) {} //*********************************** */ async getBalance(userId: string) { @@ -37,7 +45,7 @@ export class WalletsService { const transaction = queryRunner.manager.create(WalletTransaction, { amount, - wallet, + wallet: { id: wallet.id }, type: TransactionType.DEPOSIT, description: WalletMessage.DEPOSIT_WALLET_IPG, }); @@ -50,4 +58,87 @@ export class WalletsService { return transaction; } //*********************************** */ + + async getTransaction(userId: string, paginationDto: PaginationDto) { + const { skip, limit } = PaginationUtils(paginationDto); + + const [transactions, count] = await this.walletsTransactionRepo + .createQueryBuilder("transaction") + .leftJoinAndSelect("transaction.wallet", "wallet") + .where("wallet.userId = :userId", { userId }) + .select([ + "transaction.id", + "transaction.type", + "transaction.amount", + "transaction.description", + "transaction.numericId", + "transaction.createdAt", + "wallet.id", + ]) + .orderBy("transaction.createdAt", "DESC") + .skip(skip) + .take(limit) + .getManyAndCount(); + + const totals = await this.walletsTransactionRepo + .createQueryBuilder("transaction") + .leftJoin("transaction.wallet", "wallet") + .where("wallet.userId = :userId", { userId }) + .select([ + `COALESCE(SUM(CASE WHEN transaction.type = '${TransactionType.DEPOSIT}' THEN transaction.amount ELSE 0 END), 0) AS "totalDeposits"`, + `COALESCE(SUM(CASE WHEN transaction.type = '${TransactionType.WITHDRAWAL}' THEN transaction.amount ELSE 0 END), 0) AS "totalWithdrawals"`, + ]) + .getRawOne(); + + const wallet = await this.walletsRepository.findOneBy({ user: { id: userId } }); + + return { + balance: wallet?.balance, + transactions, + count, + totalDeposits: decimal(totals.totalDeposits) || 0, + totalWithdrawals: decimal(totals.totalWithdrawals) || 0, + paginate: true, + }; + } + + //*********************************** */ + + async getTransactionsForAdmin(queryDto: SearchTransactionQueryDto) { + const { limit, skip } = PaginationUtils(queryDto); + + const queryBuilder = this.walletsTransactionRepo.createQueryBuilder("transaction"); + + queryBuilder + .leftJoinAndSelect("transaction.wallet", "wallet") + // .leftJoinAndSelect("transaction.payment", "payment") + .leftJoinAndSelect("wallet.user", "user"); + + if (queryDto.user) { + queryBuilder.andWhere("(user.firstName LIKE :user OR user.lastName LIKE :user OR user.email LIKE :user)", { + user: `%${queryDto.user}%`, + }); + } + + if (queryDto.date) { + queryBuilder.andWhere("transaction.createdAt >= :date", { date: new Date(queryDto.date) }); + } + + // if (queryDto.status) { + // queryBuilder.andWhere("payment.status = :status", { status: queryDto.status }); + // } + + if (queryDto.q) { + queryBuilder.andWhere("(transaction.description LIKE :q LIKE :q OR user.name LIKE :q OR user.family LIKE :q)", { + q: `%${queryDto.q}%`, + }); + } + + queryBuilder.take(limit).skip(skip); + queryBuilder.orderBy("transaction.createdAt", "DESC"); + + const [transactions, count] = await queryBuilder.getManyAndCount(); + + return { transactions, count, pagination: true }; + } } diff --git a/src/modules/wallets/wallets.controller.ts b/src/modules/wallets/wallets.controller.ts index 14cda82..0552610 100644 --- a/src/modules/wallets/wallets.controller.ts +++ b/src/modules/wallets/wallets.controller.ts @@ -1,10 +1,12 @@ -import { Controller, Get } from "@nestjs/common"; +import { Controller, Get, Query } from "@nestjs/common"; import { ApiOperation, ApiTags } from "@nestjs/swagger"; import { WalletsService } from "./providers/wallets.service"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; +import { Pagination } from "../../common/decorators/pagination.decorator"; import { Roles } from "../../common/decorators/roles.decorator"; import { UserDec } from "../../common/decorators/user.decorator"; +import { PaginationDto } from "../../common/DTO/pagination.dto"; import { RoleEnum } from "../users/enums/role.enum"; @Controller("wallets") @@ -23,6 +25,9 @@ export class WalletsController { @ApiOperation({ summary: "get wallet transactions" }) @AuthGuards() @Roles(RoleEnum.USER) + @Pagination() @Get("transactions") - getTransactions() {} + getTransactions(@UserDec("id") userId: string, @Query() paginationDto: PaginationDto) { + return this.walletsService.getTransaction(userId, paginationDto); + } }