chore: implenet the external invoice 2
This commit is contained in:
@@ -337,6 +337,16 @@ export const enum BusinessMessage {
|
|||||||
DOMAIN_VERIFICATION_TOKEN_REQUIRED = "توکن تایید دامنه مورد نیاز است",
|
DOMAIN_VERIFICATION_TOKEN_REQUIRED = "توکن تایید دامنه مورد نیاز است",
|
||||||
INSUFFICIENT_QUOTA = "حجم شما برای ساخت ایمیل کافی نمی باشد",
|
INSUFFICIENT_QUOTA = "حجم شما برای ساخت ایمیل کافی نمی باشد",
|
||||||
QUOTA_DOWNGRADE_NOT_ALLOWED = "کاهش حجم ایمیل مجاز نیست، فقط میتوانید حجم را افزایش دهید",
|
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 {
|
export const enum DomainMessage {
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import { FactoryProvider } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
|
||||||
|
export const DANAKCORP_CONFIG = "DANAKCORP_CONFIG";
|
||||||
|
|
||||||
|
export const danakCorpConfig: FactoryProvider<IDanakCorpConfig> = {
|
||||||
|
provide: DANAKCORP_CONFIG,
|
||||||
|
inject: [ConfigService],
|
||||||
|
useFactory: (configService: ConfigService) => ({
|
||||||
|
apiUrl: configService.getOrThrow<string>("DANAKCORP_API_URL"),
|
||||||
|
apiKey: configService.getOrThrow<string>("DANAKCORP_API_KEY"),
|
||||||
|
timeout: configService.get<number>("DANAKCORP_TIMEOUT", 30000),
|
||||||
|
retries: configService.get<number>("DANAKCORP_RETRIES", 3),
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
|
||||||
|
export interface IDanakCorpConfig {
|
||||||
|
apiUrl: string;
|
||||||
|
apiKey: string;
|
||||||
|
timeout: number;
|
||||||
|
retries: number;
|
||||||
|
}
|
||||||
@@ -1,18 +0,0 @@
|
|||||||
import { ConfigService } from "@nestjs/config";
|
|
||||||
|
|
||||||
export function zarinpalConfig() {
|
|
||||||
return {
|
|
||||||
inject: [ConfigService],
|
|
||||||
useFactory: (configService: ConfigService) => ({
|
|
||||||
gatewayApiUrl: configService.getOrThrow<string>("ZARINPAL_API_URL"),
|
|
||||||
callBackUrl: configService.getOrThrow<string>("CALLBACK_URL"),
|
|
||||||
ipgType: configService.getOrThrow<string>("IPG_TYPE"),
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface IZarinpalConfig {
|
|
||||||
gatewayApiUrl: string;
|
|
||||||
callBackUrl: string;
|
|
||||||
ipgType: string;
|
|
||||||
}
|
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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 { ApiHeader, ApiOperation, ApiResponse } from "@nestjs/swagger";
|
||||||
|
|
||||||
import { BusinessQuotaResponseDto } from "./DTO/business-quota-response.dto";
|
import { BusinessQuotaResponseDto } from "./DTO/business-quota-response.dto";
|
||||||
import { BusinessSlugParamDto } from "./DTO/business-slug-param.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 { UpdateBusinessSettingsDto } from "./DTO/update-business-settings.dto";
|
||||||
import { BusinessesService } from "./services/businesses.service";
|
import { BusinessesService } from "./services/businesses.service";
|
||||||
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
import { AuthGuards } from "../../common/decorators/auth-guard.decorator";
|
||||||
@@ -44,4 +45,13 @@ export class BusinessesController {
|
|||||||
getBusinessQuota(@BusinessDec("id") businessId: string) {
|
getBusinessQuota(@BusinessDec("id") businessId: string) {
|
||||||
return this.businessesService.getBusinessQuota(businessId);
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { BullModule } from "@nestjs/bullmq";
|
|||||||
import { Module } from "@nestjs/common";
|
import { Module } from "@nestjs/common";
|
||||||
|
|
||||||
import { BusinessesController } from "./businesses.controller";
|
import { BusinessesController } from "./businesses.controller";
|
||||||
import { SUBSCRIPTIONS } from "./constant";
|
import { EXTERNAL_INVOICE_QUEUE, SUBSCRIPTIONS } from "./constant";
|
||||||
import { Business } from "./entities/business.entity";
|
import { Business } from "./entities/business.entity";
|
||||||
import { BusinessProvisioningProcessor } from "./queue/business-provisioning.processor";
|
import { BusinessProvisioningProcessor } from "./queue/business-provisioning.processor";
|
||||||
|
import { ExternalInvoiceProcessor } from "./queue/external-invoice.processor";
|
||||||
import { BusinessesService } from "./services/businesses.service";
|
import { BusinessesService } from "./services/businesses.service";
|
||||||
|
import { QuotaPurchaseService } from "./services/quota-purchase.service";
|
||||||
|
import { danakCorpConfig } from "../../configs/danakcorp.config";
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [
|
imports: [
|
||||||
@@ -15,8 +18,12 @@ import { BusinessesService } from "./services/businesses.service";
|
|||||||
name: SUBSCRIPTIONS.PROVISIONING_QUEUE_NAME,
|
name: SUBSCRIPTIONS.PROVISIONING_QUEUE_NAME,
|
||||||
prefix: SUBSCRIPTIONS.PROVISIONING_QUEUE_PREFIX,
|
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],
|
exports: [BusinessesService],
|
||||||
controllers: [BusinessesController],
|
controllers: [BusinessesController],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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)
|
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
|
USER_QUOTA_DEDUCTION: 128 * 1024 * 1024, // 128MB deducted per user creation
|
||||||
DEFAULT_EMAIL_QUOTA_BYTES: 128 * 1024 * 1024, // 128MB per user in bytes
|
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",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -9,3 +9,31 @@ export interface IBusinessProvisioningJob {
|
|||||||
email: string;
|
email: string;
|
||||||
fullName: 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<string, unknown>;
|
||||||
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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<void> {
|
||||||
|
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<void> {
|
||||||
|
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<IExternalInvoiceJob>): Promise<void> {
|
||||||
|
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<Business> {
|
||||||
|
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<void> {
|
||||||
|
// 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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,10 @@
|
|||||||
import { EntityManager } from "@mikro-orm/postgresql";
|
import { EntityManager } from "@mikro-orm/postgresql";
|
||||||
import { BadRequestException, Injectable } from "@nestjs/common";
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
import { QuotaPurchaseService } from "./quota-purchase.service";
|
||||||
import { BusinessMessage } from "../../../common/enums/message.enum";
|
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 { UpdateBusinessSettingsDto } from "../DTO/update-business-settings.dto";
|
||||||
import { BusinessStaff } from "../entities/business-staff.entity";
|
import { BusinessStaff } from "../entities/business-staff.entity";
|
||||||
import { BusinessRepository } from "../repositories/business.repository";
|
import { BusinessRepository } from "../repositories/business.repository";
|
||||||
@@ -11,6 +14,7 @@ export class BusinessesService {
|
|||||||
constructor(
|
constructor(
|
||||||
private readonly businessRepository: BusinessRepository,
|
private readonly businessRepository: BusinessRepository,
|
||||||
private readonly em: EntityManager,
|
private readonly em: EntityManager,
|
||||||
|
private readonly quotaPurchaseService: QuotaPurchaseService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async getBusinessByDanakSubscriptionId(danakSubscriptionId: string) {
|
async getBusinessByDanakSubscriptionId(danakSubscriptionId: string) {
|
||||||
@@ -93,6 +97,7 @@ export class BusinessesService {
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
quota: {
|
quota: {
|
||||||
|
quotaPricePerGB: QUOTA_CONSTANTS.QUOTA_PRICE_PER_GB,
|
||||||
total: Number(business.quota),
|
total: Number(business.quota),
|
||||||
used: Number(business.usedQuota),
|
used: Number(business.usedQuota),
|
||||||
remaining: Number(business.remainingQuota),
|
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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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<IDanakCorpQuotaPurchaseResponse>(`${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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user