From 11b8164c283f609cc5cbf9a27897ff62f72df012 Mon Sep 17 00:00:00 2001 From: mahyargdz Date: Mon, 14 Jul 2025 10:24:58 +0330 Subject: [PATCH] chore: implenet the external invoice --- .../decorators/api-key-guard.decorator.ts | 11 ++ src/common/enums/message.enum.ts | 5 + src/configs/typeorm.config.ts | 2 +- src/modules/auth/guards/api-key.guard.ts | 29 +++++ .../invoices/DTO/create-invoice.dto.ts | 14 ++- src/modules/invoices/constants/index.ts | 10 ++ .../invoices/entities/invoice.entity.ts | 8 ++ .../interfaces/external-invoice.interface.ts | 11 ++ src/modules/invoices/invoices.controller.ts | 12 +- src/modules/invoices/invoices.module.ts | 10 +- .../invoices/providers/invoices.service.ts | 109 +++++++++++++++++- .../payments/factories/payment.factory.ts | 6 +- src/modules/payments/payments.module.ts | 44 +++---- 13 files changed, 241 insertions(+), 30 deletions(-) create mode 100644 src/common/decorators/api-key-guard.decorator.ts create mode 100644 src/modules/auth/guards/api-key.guard.ts create mode 100644 src/modules/invoices/interfaces/external-invoice.interface.ts diff --git a/src/common/decorators/api-key-guard.decorator.ts b/src/common/decorators/api-key-guard.decorator.ts new file mode 100644 index 0000000..7e05dc8 --- /dev/null +++ b/src/common/decorators/api-key-guard.decorator.ts @@ -0,0 +1,11 @@ +import { UseGuards, applyDecorators } from "@nestjs/common"; +import { ApiHeader } from "@nestjs/swagger"; + +import { ApiKeyGuard } from "../../modules/auth/guards/api-key.guard"; + +export function ApiKeyGuards() { + return applyDecorators( + UseGuards(ApiKeyGuard), + ApiHeader({ name: "x-api-key", description: "API key for external service authentication", required: true }), + ); +} diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index f624b4c..94212cb 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -536,6 +536,11 @@ export const enum InvoiceMessage { DISCOUNT_NOT_FOR_THIS_USER = "این تخفیف برای شما معتبر نیست", DISCOUNT_IS_NOT_ACTIVE = "تخفیف فعال نیست", DISCOUNT_NOT_STARTED = "تخفیف هنوز شروع نشده است", + DANAK_SUBSCRIPTION_ID_REQUIRED = "شناسه خرید داناک صورت حساب مورد نیاز است", + DANAK_SUBSCRIPTION_ID_SHOULD_BE_A_UUID = "شناسه خرید داناک صورت حساب باید یک UUID معتبر باشد", + DANAK_SUBSCRIPTION_NOT_FOUND = "خرید داناک با این شناسه یافت نشد", + BUSINESS_ID_REQUIRED = "شناسه کسب و کار مورد نیاز است", + BUSINESS_ID_SHOULD_BE_A_STRING = "شناسه کسب و کار باید یک رشته باشد", } export const enum LearningMessage { diff --git a/src/configs/typeorm.config.ts b/src/configs/typeorm.config.ts index 9391ab2..8d8378f 100755 --- a/src/configs/typeorm.config.ts +++ b/src/configs/typeorm.config.ts @@ -13,7 +13,7 @@ export function databaseConfigs(): TypeOrmModuleAsyncOptions { username: configService.getOrThrow("DB_USER"), password: configService.getOrThrow("DB_PASS"), autoLoadEntities: true, - synchronize: configService.getOrThrow("NODE_ENV") == "production" ? false : false, + synchronize: configService.getOrThrow("NODE_ENV") == "production" ? false : true, logging: configService.getOrThrow("NODE_ENV") == "production" ? false : true, migrationsTableName: "typeorm_migrations", migrationsRun: false, diff --git a/src/modules/auth/guards/api-key.guard.ts b/src/modules/auth/guards/api-key.guard.ts new file mode 100644 index 0000000..f16919b --- /dev/null +++ b/src/modules/auth/guards/api-key.guard.ts @@ -0,0 +1,29 @@ +import { CanActivate, ExecutionContext, Injectable, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { FastifyRequest } from "fastify"; + +import { AuthMessage } from "../../../common/enums/message.enum"; + +@Injectable() +export class ApiKeyGuard implements CanActivate { + constructor(private configService: ConfigService) {} + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + const apiKey = request.headers["x-api-key"] as string; + + if (!apiKey) throw new UnauthorizedException(AuthMessage.UNAUTHORIZED_ACCESS); + + // Get allowed API keys from environment (comma-separated) + const allowedApiKeys = this.configService.getOrThrow("EXTERNAL_API_KEYS"); + const validApiKeys = allowedApiKeys.split(",").filter((key) => key.trim().length > 0); + + if (validApiKeys.length === 0) throw new UnauthorizedException(AuthMessage.UNAUTHORIZED_ACCESS); + + const isValidApiKey = validApiKeys.includes(apiKey); + + if (!isValidApiKey) throw new UnauthorizedException(AuthMessage.UNAUTHORIZED_ACCESS); + + return true; + } +} diff --git a/src/modules/invoices/DTO/create-invoice.dto.ts b/src/modules/invoices/DTO/create-invoice.dto.ts index d951433..9122b64 100755 --- a/src/modules/invoices/DTO/create-invoice.dto.ts +++ b/src/modules/invoices/DTO/create-invoice.dto.ts @@ -1,4 +1,4 @@ -import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger"; +import { ApiProperty, ApiPropertyOptional, OmitType } from "@nestjs/swagger"; import { Type } from "class-transformer"; import { ArrayMinSize, IsBoolean, IsEnum, IsInt, IsNotEmpty, IsOptional, IsString, IsUUID, Length, ValidateNested } from "class-validator"; @@ -65,3 +65,15 @@ export class CreateInvoiceDto { @ApiPropertyOptional({ description: "Maximum number of recurring cycles (0 for unlimited)", example: 12, default: 0 }) maxRecurringCycles?: number; } + +export class CreateExternalInvoiceDto extends OmitType(CreateInvoiceDto, ["userId"]) { + @IsNotEmpty({ message: InvoiceMessage.DANAK_SUBSCRIPTION_ID_REQUIRED }) + @IsUUID("4", { message: InvoiceMessage.DANAK_SUBSCRIPTION_ID_SHOULD_BE_A_UUID }) + @ApiProperty({ description: "Danak subscription id", example: "123e4567-e89b-12d3-a456-426614174000" }) + danakSubscriptionId: string; + + @IsNotEmpty({ message: InvoiceMessage.BUSINESS_ID_REQUIRED }) + @IsString({ message: InvoiceMessage.BUSINESS_ID_SHOULD_BE_A_STRING }) + @ApiProperty({ description: "Business id", example: "123e4567-e89b-12d3-a456-426614174000" }) + businessId: string; +} diff --git a/src/modules/invoices/constants/index.ts b/src/modules/invoices/constants/index.ts index 0888d3b..6c8c09c 100755 --- a/src/modules/invoices/constants/index.ts +++ b/src/modules/invoices/constants/index.ts @@ -1,9 +1,18 @@ export const INVOICE = Object.freeze({ QUEUE_NAME: "invoice", + EXTERNAL_QUEUE_NAME: "externalInvoice", + EXTERNAL_INVOICE_JOB_NAME: "externalInvoice", + // + EXTERNAL_CALLBACK_JOB_NAME: "externalInvoice.callback", REMINDER_JOB_NAME: "reminderInvoice", RECURRING_JOB_NAME: "recurringInvoice", SUBSCRIPTION_ADMIN_NOTIFICATION_JOB_NAME: "subscriptionAdminNotification", // + EXTERNAL_JOB_PRIORITY: 1, // high priority + EXTERNAL_JOB_ATTEMPTS: 3, // retry 3 times + EXTERNAL_JOB_BACKOFF: 5 * 1000, // retry after 5 second + EXTERNAL_JOB_TIMEOUT: 10000, // timeout after 10 seconds + // REMINDER_JOB_PRIORITY: 1, // high priority REMINDER_JOB_ATTEMPTS: 3, // 3 times REMINDER_JOB_BACKOFF: 5 * 1000, // ms @@ -27,5 +36,6 @@ export const INVOICE = Object.freeze({ FINE_PERCENTAGE: 0.01, // 1% of the total price MAX_DAYS_AFTER_OVERDUE: 10, DUEDATE: 7, + EXTERNAL_INVOICE_DUEDATE: 2, // 2 days DAYS_BEFORE_OVERDUE: 3, }); diff --git a/src/modules/invoices/entities/invoice.entity.ts b/src/modules/invoices/entities/invoice.entity.ts index 801de2d..6740fb7 100755 --- a/src/modules/invoices/entities/invoice.entity.ts +++ b/src/modules/invoices/entities/invoice.entity.ts @@ -52,6 +52,14 @@ export class Invoice extends BaseEntity { //--------------------------------------------- + @Column({ type: "boolean", default: false }) + isExternal: boolean; + + @Column({ type: "varchar", length: 250, nullable: true }) + externalBusinessId: string | null; + + //--------------------------------------------- + @OneToMany(() => InvoiceItem, (invoiceItem) => invoiceItem.invoice, { cascade: true }) items: InvoiceItem[]; diff --git a/src/modules/invoices/interfaces/external-invoice.interface.ts b/src/modules/invoices/interfaces/external-invoice.interface.ts new file mode 100644 index 0000000..5af7e1f --- /dev/null +++ b/src/modules/invoices/interfaces/external-invoice.interface.ts @@ -0,0 +1,11 @@ +import { InvoiceItem } from "../entities/invoice-item.entity"; +import { Invoice } from "../entities/invoice.entity"; + +export interface IExternalInvoiceJob { + invoice: Invoice; + items: InvoiceItem[]; + businessId: string; + danakSubscriptionId: string; + timestamp: string; + // callbackData?: Record; +} diff --git a/src/modules/invoices/invoices.controller.ts b/src/modules/invoices/invoices.controller.ts index f44c23d..79dc598 100755 --- a/src/modules/invoices/invoices.controller.ts +++ b/src/modules/invoices/invoices.controller.ts @@ -2,12 +2,14 @@ import { Body, Controller, Get, Param, Patch, Post, Query } from "@nestjs/common import { ApiOperation } from "@nestjs/swagger"; import { ApplyDiscountDto } from "./DTO/apply-discount.dto"; -import { CreateInvoiceDto } from "./DTO/create-invoice.dto"; +import { CreateExternalInvoiceDto, CreateInvoiceDto } from "./DTO/create-invoice.dto"; import { InvoicesSearchQueryDto, UserInvoicesSearchQueryDto } from "./DTO/invoices-search-query.dto"; import { UpdateInvoiceDto } from "./DTO/update-invoice.dto"; import { InvoicesService } from "./providers/invoices.service"; +import { ApiKeyGuards } from "../../common/decorators/api-key-guard.decorator"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; import { PermissionsDec } from "../../common/decorators/permission.decorator"; +import { SkipAuth } from "../../common/decorators/skip-auth.decorator"; import { UserDec } from "../../common/decorators/user.decorator"; import { ParamDto } from "../../common/DTO/param.dto"; import { VerifyOtpWithUserId } from "../auth/DTO/verify-otp.dto"; @@ -80,4 +82,12 @@ export class InvoicesController { cancelDiscount(@Param() paramDto: ParamDto, @UserDec("id") userId: string) { return this.invoiceService.cancelDiscount(paramDto.id, userId); } + + @ApiOperation({ summary: "create invoice for the third party service" }) + @SkipAuth() + @ApiKeyGuards() + @Post("external/create") + createInvoiceForExternalService(@Body() createInvoiceDto: CreateExternalInvoiceDto) { + return this.invoiceService.createInvoiceForExternalService(createInvoiceDto); + } } diff --git a/src/modules/invoices/invoices.module.ts b/src/modules/invoices/invoices.module.ts index 3d34086..346aa9b 100755 --- a/src/modules/invoices/invoices.module.ts +++ b/src/modules/invoices/invoices.module.ts @@ -27,7 +27,15 @@ import { WalletsModule } from "../wallets/wallets.module"; name: INVOICE.QUEUE_NAME, defaultJobOptions: { removeOnComplete: true, - removeOnFail: true, + removeOnFail: false, + }, + }), + BullModule.registerQueue({ + name: INVOICE.EXTERNAL_QUEUE_NAME, + prefix: INVOICE.EXTERNAL_INVOICE_JOB_NAME, + defaultJobOptions: { + removeOnComplete: true, + removeOnFail: false, }, }), TypeOrmModule.forFeature([Invoice, InvoiceItem, Discount, UsageDiscount]), diff --git a/src/modules/invoices/providers/invoices.service.ts b/src/modules/invoices/providers/invoices.service.ts index 042e583..622160c 100755 --- a/src/modules/invoices/providers/invoices.service.ts +++ b/src/modules/invoices/providers/invoices.service.ts @@ -24,12 +24,13 @@ import { SmsService } from "../../utils/providers/sms.service"; import { Wallet } from "../../wallets/entities/wallet.entity"; import { WalletsService } from "../../wallets/providers/wallets.service"; import { INVOICE } from "../constants"; -import { CreateInvoiceDto, InvoiceItemDto } from "../DTO/create-invoice.dto"; +import { CreateExternalInvoiceDto, CreateInvoiceDto, InvoiceItemDto } from "../DTO/create-invoice.dto"; import { InvoicesSearchQueryDto, UserInvoicesSearchQueryDto } from "../DTO/invoices-search-query.dto"; import { UpdateInvoiceDto } from "../DTO/update-invoice.dto"; import { Invoice } from "../entities/invoice.entity"; import { RecurringPeriodEnum } from "../enums/invoice-recurring-period.enum"; import { InvoiceStatus } from "../enums/invoice-status.enum"; +import { IExternalInvoiceJob } from "../interfaces/external-invoice.interface"; import { InvoiceItemsRepository } from "../repositories/invoice-items.repository"; import { InvoicesRepository } from "../repositories/invoices.repository"; @Injectable() @@ -38,6 +39,7 @@ export class InvoicesService { constructor( @InjectQueue(INVOICE.QUEUE_NAME) private readonly invoiceQueue: Queue, + @InjectQueue(INVOICE.EXTERNAL_QUEUE_NAME) private readonly externalInvoiceQueue: Queue, private readonly notificationQueue: NotificationQueue, private readonly invoiceRepository: InvoicesRepository, private readonly invoiceItemsRepository: InvoiceItemsRepository, @@ -591,6 +593,24 @@ export class InvoicesService { await this.walletsService.createInvoiceTransaction(invoice.totalPrice, userWallet.id, queryRunner); await this.addNotifyForWalletDeduction(invoice, user, userWallet, WalletMessage.INVOICE_WALLET_TRANSFER); } + + if (invoice.isExternal && invoice.items?.[0]?.subscriptionPlan) { + const externalInvoiceJobData: IExternalInvoiceJob = { + invoice, + items: invoice.items, + businessId: invoice.externalBusinessId!, + danakSubscriptionId: invoice.items[0].subscriptionPlan.id, + timestamp: dayjs().toISOString(), + }; + + await this.externalInvoiceQueue.add(INVOICE.EXTERNAL_CALLBACK_JOB_NAME, externalInvoiceJobData, { + delay: dayjs().add(5000, "millisecond").diff(dayjs()), + attempts: INVOICE.EXTERNAL_JOB_ATTEMPTS, + backoff: { type: "exponential", delay: INVOICE.EXTERNAL_JOB_BACKOFF }, + priority: INVOICE.EXTERNAL_JOB_PRIORITY, + }); + } + invoice.status = InvoiceStatus.PAID; invoice.paidAt = dayjs().toDate(); @@ -751,6 +771,93 @@ export class InvoicesService { await queryRunner.release(); } } + + //*********************************** */ + async createInvoiceForExternalService(createDto: CreateExternalInvoiceDto) { + const queryRunner = this.dataSource.createQueryRunner(); + + try { + await queryRunner.connect(); + await queryRunner.startTransaction(); + + const subscription = await queryRunner.manager.findOne(UserSubscription, { + where: { id: createDto.danakSubscriptionId }, + relations: { user: true }, + }); + + if (!subscription) throw new BadRequestException(InvoiceMessage.DANAK_SUBSCRIPTION_NOT_FOUND); + + const user = subscription.user; + + this.validateInvoiceItems(createDto.items); + + this.validateRecurringInvoice(createDto); + + const invoiceItems = createDto.items.map((item) => ({ + name: item.name, + count: item.count, + unitPrice: item.unitPrice, + discount: item.discount || 0, + totalPrice: item.unitPrice * item.count - (item.unitPrice * item.count * item.discount) / 100, + subscriptionPlan: subscription, + })); + + const totalPrice = invoiceItems.reduce((sum, item) => new Decimal(item.totalPrice).add(sum), new Decimal(0)); + const tax = totalPrice.mul(0.1); + + if (totalPrice.lessThanOrEqualTo(0)) throw new BadRequestException(InvoiceMessage.TOTAL_PRICE_MUST_BE_POSITIVE); + + const dueDate = dayjs().add(INVOICE.EXTERNAL_INVOICE_DUEDATE, "day").toDate(); + + const invoice = queryRunner.manager.create(this.invoiceRepository.target, { + user, + totalPrice: totalPrice.add(tax).toNumber(), + originalPrice: totalPrice.add(tax).toNumber(), + items: invoiceItems, + tax: tax.toNumber(), + dueDate, + status: InvoiceStatus.PENDING, + isRecurring: createDto.isRecurring, + recurringPeriod: createDto.recurringPeriod, + maxRecurringCycles: createDto.maxRecurringCycles, + currentRecurringCycle: 0, + isExternal: true, + externalBusinessId: createDto.businessId, + }); + + await queryRunner.manager.save(this.invoiceRepository.target, invoice); + + await this.notificationQueue.addInvoiceCreationNotification(user.id, { + invoiceId: invoice.numericId.toString(), + dueDate: invoice.dueDate, + createDate: invoice.createdAt, + price: new Decimal(invoice.totalPrice).toNumber(), + userPhone: user.phone, + userEmail: user.email, + items: invoiceItems.map((item) => item.name).join(", "), + paidAt: invoice.paidAt, + }); + + await this.scheduleInvoiceJobs(invoice, createDto); + + await queryRunner.commitTransaction(); + + return { + message: InvoiceMessage.CREATED, + invoice, + }; + } catch (error) { + await queryRunner.rollbackTransaction(); + this.logger.error( + `Failed to create invoice: ${error instanceof Error ? error.message : "Unknown error"}`, + error instanceof Error ? error.stack : "", + ); + throw error; + } finally { + await queryRunner.release(); + } + } + //*********************************** */ private async getInvoiceByIdWithQueryRunner(invoiceId: string, userId: string, queryRunner: QueryRunner) { const invoice = await queryRunner.manager.findOne(Invoice, { diff --git a/src/modules/payments/factories/payment.factory.ts b/src/modules/payments/factories/payment.factory.ts index 900eb30..d12a881 100755 --- a/src/modules/payments/factories/payment.factory.ts +++ b/src/modules/payments/factories/payment.factory.ts @@ -2,7 +2,7 @@ import { BadGatewayException, Injectable } from "@nestjs/common"; import { PaymentMessage } from "../../../common/enums/message.enum"; import { GatewayEnum } from "../enums/gateway.enum"; -import { ParsianGateway } from "../gateways/parsian.gateway"; +// import { ParsianGateway } from "../gateways/parsian.gateway"; import { ZarinpalGateway } from "../gateways/zarinpal.gateway"; import { IPaymentGateway, IPaymentGatewayFactory } from "../interfaces/IPayment"; import { GatewayType } from "../types/gateway.type"; @@ -13,7 +13,7 @@ export class PaymentGatewayFactory implements IPaymentGatewayFactory { constructor( private readonly zarinpalGateway: ZarinpalGateway, - private readonly parsianGateway: ParsianGateway, + // private readonly parsianGateway: ParsianGateway, ) { this.gateways = new Map(); this.initializeGateways(); @@ -21,7 +21,7 @@ export class PaymentGatewayFactory implements IPaymentGatewayFactory { private initializeGateways(): void { this.gateways.set(GatewayEnum.ZARINPAL, this.zarinpalGateway); - this.gateways.set(GatewayEnum.PARSIAN, this.parsianGateway); + // this.gateways.set(GatewayEnum.PARSIAN, this.parsianGateway); } getPaymentGateway(provider: GatewayType): IPaymentGateway { diff --git a/src/modules/payments/payments.module.ts b/src/modules/payments/payments.module.ts index a2f84c9..b32655c 100755 --- a/src/modules/payments/payments.module.ts +++ b/src/modules/payments/payments.module.ts @@ -1,15 +1,15 @@ import { BullModule } from "@nestjs/bullmq"; import { Module } from "@nestjs/common"; import { TypeOrmModule } from "@nestjs/typeorm"; -import { SoapModule } from "nestjs-soap"; +// import { SoapModule } from "nestjs-soap"; -import { PARSIAN_CONFIG, PARSIAN_CONFIRM_CLIENT, PARSIAN_SALE_CLIENT, PAYMENT, ZARINPAL_CONFIG } from "./constants"; +import { PAYMENT, ZARINPAL_CONFIG } from "./constants"; 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 { PaymentGatewayFactory } from "./factories/payment.factory"; -import { ParsianGateway } from "./gateways/parsian.gateway"; +// import { ParsianGateway } from "./gateways/parsian.gateway"; import { ZarinpalGateway } from "./gateways/zarinpal.gateway"; import { PaymentsController } from "./payments.controller"; import { PaymentsService } from "./providers/payments.service"; @@ -19,7 +19,7 @@ import { WalletsModule } from "../wallets/wallets.module"; import { DepositRequestsRepository } from "./repositories/deposit-requests.repository"; import { PaymentGatewaysRepository } from "./repositories/payment-gateway.repository"; import { PaymentsRepository } from "./repositories/payments.repository"; -import { ParsianConfiguration, parsianConfig } from "../../configs/parsian.config"; +// import { ParsianConfiguration, parsianConfig } from "../../configs/parsian.config"; import { zarinpalConfig } from "../../configs/zarinpal.config"; import { NotificationModule } from "../notifications/notifications.module"; import { ReferralsModule } from "../referrals/referrals.module"; @@ -34,18 +34,18 @@ import { ReferralsModule } from "../referrals/referrals.module"; removeOnFail: false, }, }), - SoapModule.registerAsync({ - clientName: PARSIAN_SALE_CLIENT, - useFactory: () => ({ - uri: ParsianConfiguration.WsdlInitial, - }), - }), - SoapModule.registerAsync({ - clientName: PARSIAN_CONFIRM_CLIENT, - useFactory: () => ({ - uri: ParsianConfiguration.WsdlConfirm, - }), - }), + // SoapModule.registerAsync({ + // clientName: PARSIAN_SALE_CLIENT, + // useFactory: () => ({ + // uri: ParsianConfiguration.WsdlInitial, + // }), + // }), + // SoapModule.registerAsync({ + // clientName: PARSIAN_CONFIRM_CLIENT, + // useFactory: () => ({ + // uri: ParsianConfiguration.WsdlConfirm, + // }), + // }), WalletsModule, NotificationModule, ReferralsModule, @@ -55,7 +55,7 @@ import { ReferralsModule } from "../referrals/referrals.module"; PaymentsService, PaymentGatewayFactory, ZarinpalGateway, - ParsianGateway, + // ParsianGateway, PaymentsRepository, BankAccountsRepository, PaymentGatewaysRepository, @@ -66,11 +66,11 @@ import { ReferralsModule } from "../referrals/referrals.module"; useFactory: zarinpalConfig().useFactory, inject: zarinpalConfig().inject, }, - { - provide: PARSIAN_CONFIG, - useFactory: parsianConfig().useFactory, - inject: parsianConfig().inject, - }, + // { + // provide: PARSIAN_CONFIG, + // useFactory: parsianConfig().useFactory, + // inject: parsianConfig().inject, + // }, ], exports: [PaymentsService], })