diff --git a/src/common/enums/message.enum.ts b/src/common/enums/message.enum.ts index b022261..b726c77 100755 --- a/src/common/enums/message.enum.ts +++ b/src/common/enums/message.enum.ts @@ -337,6 +337,16 @@ export const enum BusinessMessage { DOMAIN_VERIFICATION_TOKEN_REQUIRED = "توکن تایید دامنه مورد نیاز است", INSUFFICIENT_QUOTA = "حجم شما برای ساخت ایمیل کافی نمی باشد", QUOTA_DOWNGRADE_NOT_ALLOWED = "کاهش حجم ایمیل مجاز نیست، فقط می‌توانید حجم را افزایش دهید", + QUOTA_AMOUNT_REQUIRED = "مقدار حجم مورد نیاز است", + QUOTA_AMOUNT_MUST_BE_NUMBER = "مقدار حجم باید یک عدد باشد", + QUOTA_AMOUNT_MUST_BE_POSITIVE = "مقدار حجم باید مثبت باشد", + CALLBACK_URL_MUST_BE_STRING = "آدرس بازگشت باید یک رشته باشد", + CALLBACK_URL_INVALID = "آدرس بازگشت معتبر نیست", + PURCHASE_DESCRIPTION_MUST_BE_STRING = "توضیحات خرید باید یک رشته باشد", + QUOTA_PURCHASE_SUCCESS = "درخواست خرید حجم با موفقیت ثبت شد", + QUOTA_PURCHASE_FAILED = "خرید حجم با خطا مواجه شد", + PAYMENT_GATEWAY_ERROR = "خطا در اتصال به درگاه پرداخت", + QUOTA_UPDATED = "حجم ایمیل با موفقیت به روز شد", } export const enum DomainMessage { diff --git a/src/configs/danakcorp.config.ts b/src/configs/danakcorp.config.ts new file mode 100644 index 0000000..cd8da13 --- /dev/null +++ b/src/configs/danakcorp.config.ts @@ -0,0 +1,22 @@ +import { FactoryProvider } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; + +export const DANAKCORP_CONFIG = "DANAKCORP_CONFIG"; + +export const danakCorpConfig: FactoryProvider = { + provide: DANAKCORP_CONFIG, + inject: [ConfigService], + useFactory: (configService: ConfigService) => ({ + apiUrl: configService.getOrThrow("DANAKCORP_API_URL"), + apiKey: configService.getOrThrow("DANAKCORP_API_KEY"), + timeout: configService.get("DANAKCORP_TIMEOUT", 30000), + retries: configService.get("DANAKCORP_RETRIES", 3), + }), +}; + +export interface IDanakCorpConfig { + apiUrl: string; + apiKey: string; + timeout: number; + retries: number; +} diff --git a/src/configs/zarinpal.config.ts b/src/configs/zarinpal.config.ts deleted file mode 100755 index 97c5dc9..0000000 --- a/src/configs/zarinpal.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { ConfigService } from "@nestjs/config"; - -export function zarinpalConfig() { - return { - inject: [ConfigService], - useFactory: (configService: ConfigService) => ({ - gatewayApiUrl: configService.getOrThrow("ZARINPAL_API_URL"), - callBackUrl: configService.getOrThrow("CALLBACK_URL"), - ipgType: configService.getOrThrow("IPG_TYPE"), - }), - }; -} - -export interface IZarinpalConfig { - gatewayApiUrl: string; - callBackUrl: string; - ipgType: string; -} diff --git a/src/modules/businesses/DTO/purchase-quota.dto.ts b/src/modules/businesses/DTO/purchase-quota.dto.ts new file mode 100644 index 0000000..78d0fdf --- /dev/null +++ b/src/modules/businesses/DTO/purchase-quota.dto.ts @@ -0,0 +1,17 @@ +import { ApiProperty } from "@nestjs/swagger"; +import { IsNotEmpty, IsNumber, IsOptional, IsString, Min } from "class-validator"; + +import { BusinessMessage } from "../../../common/enums/message.enum"; +// example: 1073741824, // 1GB +export class PurchaseQuotaDto { + @IsNotEmpty({ message: BusinessMessage.QUOTA_AMOUNT_REQUIRED }) + @IsNumber({}, { message: BusinessMessage.QUOTA_AMOUNT_MUST_BE_NUMBER }) + @Min(1, { message: BusinessMessage.QUOTA_AMOUNT_MUST_BE_POSITIVE }) + @ApiProperty({ description: "Additional quota count to purchase in bytes (1024 * 1024 * 1024 = 1GB)", example: 1073741824 }) + quota: number; + + @IsOptional() + @IsString({ message: BusinessMessage.PURCHASE_DESCRIPTION_MUST_BE_STRING }) + @ApiProperty({ description: "Description for the quota purchase", example: "Additional email storage for business", required: false }) + description?: string; +} diff --git a/src/modules/businesses/businesses.controller.ts b/src/modules/businesses/businesses.controller.ts index 73553bf..d455a67 100644 --- a/src/modules/businesses/businesses.controller.ts +++ b/src/modules/businesses/businesses.controller.ts @@ -1,8 +1,9 @@ -import { Body, Controller, Get, Param, Patch, UseInterceptors } from "@nestjs/common"; +import { Body, Controller, Get, Param, Patch, Post, UseInterceptors } from "@nestjs/common"; import { ApiHeader, ApiOperation, ApiResponse } from "@nestjs/swagger"; import { BusinessQuotaResponseDto } from "./DTO/business-quota-response.dto"; import { BusinessSlugParamDto } from "./DTO/business-slug-param.dto"; +import { PurchaseQuotaDto } from "./DTO/purchase-quota.dto"; import { UpdateBusinessSettingsDto } from "./DTO/update-business-settings.dto"; import { BusinessesService } from "./services/businesses.service"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; @@ -44,4 +45,13 @@ export class BusinessesController { getBusinessQuota(@BusinessDec("id") businessId: string) { return this.businessesService.getBusinessQuota(businessId); } + + @Post("quota/purchase") + @UseInterceptors(BusinessInterceptor) + @ApiOperation({ summary: "Purchase additional quota for business" }) + @ApiResponse({ status: 201, description: "Quota purchase initiated successfully" }) + @AuthGuards() + purchaseQuota(@BusinessDec("id") businessId: string, @Body() purchaseDto: PurchaseQuotaDto) { + return this.businessesService.purchaseQuota(businessId, purchaseDto); + } } diff --git a/src/modules/businesses/businesses.module.ts b/src/modules/businesses/businesses.module.ts index 7c1932a..60679e6 100644 --- a/src/modules/businesses/businesses.module.ts +++ b/src/modules/businesses/businesses.module.ts @@ -3,10 +3,13 @@ import { BullModule } from "@nestjs/bullmq"; import { Module } from "@nestjs/common"; import { BusinessesController } from "./businesses.controller"; -import { SUBSCRIPTIONS } from "./constant"; +import { EXTERNAL_INVOICE_QUEUE, SUBSCRIPTIONS } from "./constant"; import { Business } from "./entities/business.entity"; import { BusinessProvisioningProcessor } from "./queue/business-provisioning.processor"; +import { ExternalInvoiceProcessor } from "./queue/external-invoice.processor"; import { BusinessesService } from "./services/businesses.service"; +import { QuotaPurchaseService } from "./services/quota-purchase.service"; +import { danakCorpConfig } from "../../configs/danakcorp.config"; @Module({ imports: [ @@ -15,8 +18,12 @@ import { BusinessesService } from "./services/businesses.service"; name: SUBSCRIPTIONS.PROVISIONING_QUEUE_NAME, prefix: SUBSCRIPTIONS.PROVISIONING_QUEUE_PREFIX, }), + BullModule.registerQueue({ + name: EXTERNAL_INVOICE_QUEUE.QUEUE_NAME, + prefix: EXTERNAL_INVOICE_QUEUE.QUEUE_PREFIX, + }), ], - providers: [BusinessesService, BusinessProvisioningProcessor], + providers: [BusinessesService, BusinessProvisioningProcessor, ExternalInvoiceProcessor, QuotaPurchaseService, danakCorpConfig], exports: [BusinessesService], controllers: [BusinessesController], }) diff --git a/src/modules/businesses/constant/index.ts b/src/modules/businesses/constant/index.ts index fb0e8dc..5a949fa 100644 --- a/src/modules/businesses/constant/index.ts +++ b/src/modules/businesses/constant/index.ts @@ -12,4 +12,12 @@ export const QUOTA_CONSTANTS = Object.freeze({ INITIAL_BUSINESS_QUOTA: 1024 * 1024 * 1024, // 1GB initial quota when purchasing dmail service (allows 1 user) USER_QUOTA_DEDUCTION: 128 * 1024 * 1024, // 128MB deducted per user creation DEFAULT_EMAIL_QUOTA_BYTES: 128 * 1024 * 1024, // 128MB per user in bytes + QUOTA_PRICE_PER_GB: 100_000, //TOMAN + QUOTA_INVOICE_NAME: "(به گیگابایت) حجم ایمیل برای سرویس دمیل", +}); + +export const EXTERNAL_INVOICE_QUEUE = Object.freeze({ + QUEUE_PREFIX: "externalInvoice", + QUEUE_NAME: "externalInvoice", + EXTERNAL_INVOICE_JOB_NAME: "externalInvoice.callback", }); diff --git a/src/modules/businesses/interfaces/IBusiness.ts b/src/modules/businesses/interfaces/IBusiness.ts index a0ff151..9e43106 100644 --- a/src/modules/businesses/interfaces/IBusiness.ts +++ b/src/modules/businesses/interfaces/IBusiness.ts @@ -9,3 +9,31 @@ export interface IBusinessProvisioningJob { email: string; fullName: string; } + +export enum InvoiceStatus { + DRAFT = "DRAFT", + PENDING = "PENDING", + WAIT_PAYMENT = "WAIT_PAYMENT", + PAID = "PAID", + ARCHIVED = "ARCHIVED", + OVERDUE = "OVERDUE", +} + +export interface IExternalInvoiceJob { + invoice: { + id: string; + status: InvoiceStatus; + [key: string]: unknown; + }; + items: { + id: string; + name: string; + count: number; + unitPrice: number; + discount: number; + }[]; + businessId: string; + danakSubscriptionId: string; + timestamp: string; + // callbackData?: Record; +} diff --git a/src/modules/businesses/interfaces/IQuotaPurchase.ts b/src/modules/businesses/interfaces/IQuotaPurchase.ts new file mode 100644 index 0000000..15e1dd2 --- /dev/null +++ b/src/modules/businesses/interfaces/IQuotaPurchase.ts @@ -0,0 +1,25 @@ +export class InvoiceItemDto { + name: string; + count: number; + unitPrice: number; + discount: number; +} + +export interface IDanakCorpQuotaPurchaseRequest { + businessId: string; + danakSubscriptionId: string; + quotaAmount: number; + items: InvoiceItemDto[]; + description?: string; +} + +export interface IDanakCorpQuotaPurchaseResponse { + success: boolean; + data?: { + invoice: { + id: string; + [key: string]: unknown; + }; + }; + error?: string; +} diff --git a/src/modules/businesses/queue/external-invoice.processor.ts b/src/modules/businesses/queue/external-invoice.processor.ts new file mode 100644 index 0000000..38055d8 --- /dev/null +++ b/src/modules/businesses/queue/external-invoice.processor.ts @@ -0,0 +1,183 @@ +import { EntityManager } from "@mikro-orm/postgresql"; +import { Processor } from "@nestjs/bullmq"; +import { Logger } from "@nestjs/common"; +import { Job } from "bullmq"; + +import { WorkerProcessor } from "../../../common/queues/worker.processor"; +import { EXTERNAL_INVOICE_QUEUE } from "../constant"; +import { Business } from "../entities/business.entity"; +import { IExternalInvoiceJob, InvoiceStatus } from "../interfaces/IBusiness"; + +@Processor(EXTERNAL_INVOICE_QUEUE.QUEUE_NAME) +export class ExternalInvoiceProcessor extends WorkerProcessor { + protected readonly logger = new Logger(ExternalInvoiceProcessor.name); + + constructor(private readonly em: EntityManager) { + super(); + } + + async process(job: Job): Promise { + const startTime = Date.now(); + + try { + await this.handleJob(job); + this.logJobSuccess(job, startTime); + } catch (error) { + this.logJobFailure(job, error, startTime); + throw error; + } + } + + private async handleJob(job: Job): Promise { + if (job.name !== EXTERNAL_INVOICE_QUEUE.EXTERNAL_INVOICE_JOB_NAME) { + throw new Error(`Unsupported job type: ${job.name}`); + } + + this.validateJobData(job.data); + await this.processExternalInvoice(job); + } + //************************************************** */ + private async processExternalInvoice(job: Job): Promise { + const { invoice, businessId, items } = job.data; + + this.logger.log(`Processing external invoice ${invoice.id} for business ${businessId}`); + + const em = this.em.fork(); + await em.begin(); + + try { + const business = await this.findBusiness(em, businessId); + this.ensureInvoicePaid(invoice); + + const quotaToAdd = this.calculateQuotaAmount(items); + await this.addQuotaToBusiness(em, business, quotaToAdd); + + await em.commit(); + this.logSuccess(invoice.id, business, quotaToAdd); + } catch (error) { + await em.rollback(); + throw error; + } + } + + //************************************************** */ + private validateJobData(data: IExternalInvoiceJob): void { + if (!data) throw new Error("Job data is missing"); + + this.validateInvoice(data.invoice); + this.validateItems(data.items); + this.validateString(data.businessId, "businessId"); + this.validateString(data.danakSubscriptionId, "danakSubscriptionId"); + this.validateString(data.timestamp, "timestamp"); + } + + //************************************************** */ + private validateInvoice(invoice: IExternalInvoiceJob["invoice"]): void { + if (!invoice) throw new Error("Invoice is missing"); + + this.validateString(invoice.id, "invoice.id"); + + if (!Object.values(InvoiceStatus).includes(invoice.status)) { + throw new Error(`Invalid invoice status: ${invoice.status}`); + } + } + + //************************************************** */ + private validateItems(items: IExternalInvoiceJob["items"]): void { + if (!Array.isArray(items) || items.length === 0) { + throw new Error("Items array is missing or empty"); + } + + items.forEach((item, index) => { + this.validateString(item.id, `item[${index}].id`); + this.validateString(item.name, `item[${index}].name`); + this.validatePositiveNumber(item.count, `item[${index}].count`); + this.validatePositiveNumber(item.unitPrice, `item[${index}].unitPrice`); + this.validateNonNegativeNumber(item.discount, `item[${index}].discount`); + }); + } + + //************************************************** */ + private validateString(value: string, fieldName: string): void { + if (!value || typeof value !== "string") { + throw new Error(`${fieldName} is missing or invalid`); + } + } + + //************************************************** */ + private validatePositiveNumber(value: number, fieldName: string): void { + if (typeof value !== "number" || value <= 0) { + throw new Error(`${fieldName} must be a positive number`); + } + } + + //************************************************** */ + private validateNonNegativeNumber(value: number, fieldName: string): void { + if (typeof value !== "number" || value < 0) { + throw new Error(`${fieldName} must be a non-negative number`); + } + } + + //************************************************** */ + private async findBusiness(em: EntityManager, businessId: string): Promise { + const business = await em.findOne(Business, { id: businessId, deletedAt: null }); + + if (!business) { + throw new Error(`Business not found: ${businessId}`); + } + + return business; + } + + //************************************************** */ + private ensureInvoicePaid(invoice: { id: string; status: InvoiceStatus }): void { + if (invoice.status !== InvoiceStatus.PAID) { + throw new Error(`Invoice ${invoice.id} is not paid. Status: ${invoice.status}`); + } + } + + //************************************************** */ + private calculateQuotaAmount(items: IExternalInvoiceJob["items"]): number { + const totalGB = items.reduce((sum, item) => sum + item.count, 0); + + if (totalGB <= 0) { + throw new Error(`Invalid quota amount: ${totalGB} GB`); + } + + return totalGB * 1024 * 1024 * 1024; // Convert GB to bytes + } + + //************************************************** */ + private async addQuotaToBusiness(em: EntityManager, business: Business, quotaBytes: number): Promise { + // Convert BigInt values to numbers for arithmetic, then back to BigInt for storage + const currentQuota = Number(business.quota); + const currentRemaining = Number(business.remainingQuota); + + business.quota = currentQuota + quotaBytes; + business.remainingQuota = currentRemaining + quotaBytes; + await em.flush(); + } + + //************************************************** */ + private logSuccess(invoiceId: string, business: Business, quotaAdded: number): void { + const quotaGB = Math.round(quotaAdded / (1024 * 1024 * 1024)); + const totalQuota = Number(business.quota); + const totalQuotaGB = Math.round(totalQuota / (1024 * 1024 * 1024)); + + this.logger.log(`Successfully processed invoice ${invoiceId} for ${business.name}. Added ${quotaGB} GB quota. Total quota: ${totalQuotaGB} GB.`); + } + //************************************************** */ + + private logJobSuccess(job: Job, startTime: number): void { + const duration = Date.now() - startTime; + this.logger.log(`Job ${job.name} completed in ${duration}ms`); + } + //************************************************** */ + + private logJobFailure(job: Job, error: unknown, startTime: number): void { + const duration = Date.now() - startTime; + const message = error instanceof Error ? error.message : String(error); + + this.logger.error(`Job ${job.name} failed after ${duration}ms: ${message}`); + } +} diff --git a/src/modules/businesses/services/businesses.service.ts b/src/modules/businesses/services/businesses.service.ts index 9b44a94..51935da 100644 --- a/src/modules/businesses/services/businesses.service.ts +++ b/src/modules/businesses/services/businesses.service.ts @@ -1,7 +1,10 @@ import { EntityManager } from "@mikro-orm/postgresql"; import { BadRequestException, Injectable } from "@nestjs/common"; +import { QuotaPurchaseService } from "./quota-purchase.service"; import { BusinessMessage } from "../../../common/enums/message.enum"; +import { QUOTA_CONSTANTS } from "../constant"; +import { PurchaseQuotaDto } from "../DTO/purchase-quota.dto"; import { UpdateBusinessSettingsDto } from "../DTO/update-business-settings.dto"; import { BusinessStaff } from "../entities/business-staff.entity"; import { BusinessRepository } from "../repositories/business.repository"; @@ -11,6 +14,7 @@ export class BusinessesService { constructor( private readonly businessRepository: BusinessRepository, private readonly em: EntityManager, + private readonly quotaPurchaseService: QuotaPurchaseService, ) {} async getBusinessByDanakSubscriptionId(danakSubscriptionId: string) { @@ -93,6 +97,7 @@ export class BusinessesService { return { quota: { + quotaPricePerGB: QUOTA_CONSTANTS.QUOTA_PRICE_PER_GB, total: Number(business.quota), used: Number(business.usedQuota), remaining: Number(business.remainingQuota), @@ -106,4 +111,12 @@ export class BusinessesService { }, }; } + + //************************************************** */ + async purchaseQuota(businessId: string, purchaseDto: PurchaseQuotaDto) { + const business = await this.businessRepository.findOne({ id: businessId, deletedAt: null }); + if (!business) throw new BadRequestException(BusinessMessage.NOT_FOUND); + + return this.quotaPurchaseService.purchaseQuota(business, purchaseDto); + } } diff --git a/src/modules/businesses/services/quota-purchase.service.ts b/src/modules/businesses/services/quota-purchase.service.ts new file mode 100644 index 0000000..d61f1e4 --- /dev/null +++ b/src/modules/businesses/services/quota-purchase.service.ts @@ -0,0 +1,83 @@ +import { HttpService } from "@nestjs/axios"; +import { Inject, Injectable, InternalServerErrorException, Logger } from "@nestjs/common"; +import { AxiosError } from "axios"; +import { catchError, firstValueFrom } from "rxjs"; + +import { BusinessMessage } from "../../../common/enums/message.enum"; +import { DANAKCORP_CONFIG, IDanakCorpConfig } from "../../../configs/danakcorp.config"; +import { QUOTA_CONSTANTS } from "../constant"; +import { PurchaseQuotaDto } from "../DTO/purchase-quota.dto"; +import { Business } from "../entities/business.entity"; +import { IDanakCorpQuotaPurchaseRequest, IDanakCorpQuotaPurchaseResponse } from "../interfaces/IQuotaPurchase"; + +@Injectable() +export class QuotaPurchaseService { + private readonly logger = new Logger(QuotaPurchaseService.name); + + constructor( + @Inject(DANAKCORP_CONFIG) private readonly danakCorpConfig: IDanakCorpConfig, + private readonly httpService: HttpService, + ) {} + + async purchaseQuota(business: Business, purchaseDto: PurchaseQuotaDto) { + const { quota: quotaAmount, description } = purchaseDto; + + this.logger.log( + `Initiating quota purchase for business ${business.id} - Amount: ${quotaAmount} bytes (${Math.round(quotaAmount / (1024 * 1024 * 1024))} GB)`, + ); + + const requestPayload: IDanakCorpQuotaPurchaseRequest = { + businessId: business.id, + danakSubscriptionId: business.danakSubscriptionId, + quotaAmount, + items: [ + { + name: QUOTA_CONSTANTS.QUOTA_INVOICE_NAME, + count: Math.round(quotaAmount / (1024 * 1024 * 1024)), + unitPrice: QUOTA_CONSTANTS.QUOTA_PRICE_PER_GB, + discount: 0, + }, + ], + description: description || `Purchase ${Math.round(quotaAmount / (1024 * 1024 * 1024))} GB quota for ${business.name}`, + }; + + try { + const { data } = await firstValueFrom( + this.httpService + .post(`${this.danakCorpConfig.apiUrl}/invoices/external/create`, requestPayload, { + headers: { + "Content-Type": "application/json", + "X-API-Key": this.danakCorpConfig.apiKey, + }, + timeout: this.danakCorpConfig.timeout, + }) + .pipe( + catchError((error: AxiosError) => { + this.logger.error(`Failed to purchase quota for business ${business.id}:`, error.response?.data || error.message); + throw new InternalServerErrorException(BusinessMessage.PAYMENT_GATEWAY_ERROR); + }), + ), + ); + + if (!data.success || !data.data) { + this.logger.error(`DanakCorp API returned error for business ${business.id}:`, data.error); + throw new InternalServerErrorException(BusinessMessage.QUOTA_PURCHASE_FAILED); + } + + const response = { + message: BusinessMessage.QUOTA_PURCHASE_SUCCESS, + invoice: data.data.invoice, + }; + + this.logger.log( + `Quota purchase initiated successfully for business ${business.id} - Invoice ID: ${data.data.invoice.id}. DanakCorp will directly enqueue completion job.`, + ); + + return response; + } catch (error) { + this.logger.error(`Error purchasing quota for business ${business.id}:`, error); + if (error instanceof InternalServerErrorException) throw error; + throw new InternalServerErrorException(BusinessMessage.PAYMENT_GATEWAY_ERROR); + } + } +}