From 934a87330de28894eba7de12832a983cb574b14e Mon Sep 17 00:00:00 2001 From: barsa Date: Fri, 26 Dec 2025 17:36:06 +0900 Subject: [PATCH] Refactor Billing Controller to Improve Error Handling - Replaced inline error throwing with NotFoundException in the BillingController to enhance error handling and provide clearer responses for missing WHMCS client mappings. - Updated multiple methods to ensure consistent error management across the controller, improving maintainability and clarity in API responses. --- apps/bff/src/infra/mappers/user.mapper.ts | 32 +-- .../modules/auth/application/auth.facade.ts | 6 +- apps/bff/src/modules/auth/auth.module.ts | 6 +- .../password-workflow.service.ts | 6 +- .../signup-workflow.service.ts | 4 +- .../whmcs-link-workflow.service.ts | 0 .../src/modules/billing/billing.controller.ts | 32 +-- .../bff/src/modules/billing/billing.module.ts | 8 +- apps/bff/src/modules/billing/index.ts | 5 - .../services/invoice-health.service.ts | 202 ----------------- .../services/invoice-retrieval.service.ts | 13 +- .../services/invoices-orchestrator.service.ts | 206 ------------------ .../billing/types/invoice-monitoring.types.ts | 26 --- .../providers/whmcs-utils/custom-fields.ts | 84 +++++++ .../common/providers/whmcs-utils/encoding.ts | 79 +++++++ .../common/providers/whmcs-utils/index.ts | 12 + .../common/providers/whmcs-utils/normalize.ts | 34 +++ .../common/providers/whmcs-utils/parsing.ts | 29 +++ .../providers/whmcs-utils/php-serialize.ts | 34 +++ 19 files changed, 309 insertions(+), 509 deletions(-) rename apps/bff/src/modules/auth/infra/workflows/{workflows => }/password-workflow.service.ts (97%) rename apps/bff/src/modules/auth/infra/workflows/{workflows => }/signup-workflow.service.ts (99%) rename apps/bff/src/modules/auth/infra/workflows/{workflows => }/whmcs-link-workflow.service.ts (100%) delete mode 100644 apps/bff/src/modules/billing/services/invoice-health.service.ts delete mode 100644 apps/bff/src/modules/billing/services/invoices-orchestrator.service.ts delete mode 100644 apps/bff/src/modules/billing/types/invoice-monitoring.types.ts create mode 100644 packages/domain/common/providers/whmcs-utils/custom-fields.ts create mode 100644 packages/domain/common/providers/whmcs-utils/encoding.ts create mode 100644 packages/domain/common/providers/whmcs-utils/index.ts create mode 100644 packages/domain/common/providers/whmcs-utils/normalize.ts create mode 100644 packages/domain/common/providers/whmcs-utils/parsing.ts create mode 100644 packages/domain/common/providers/whmcs-utils/php-serialize.ts diff --git a/apps/bff/src/infra/mappers/user.mapper.ts b/apps/bff/src/infra/mappers/user.mapper.ts index c742a75d..46432330 100644 --- a/apps/bff/src/infra/mappers/user.mapper.ts +++ b/apps/bff/src/infra/mappers/user.mapper.ts @@ -1,10 +1,11 @@ /** * User DB Mapper * - * Adapts @prisma/client User to domain UserAuth type + * Adapts @prisma/client User to domain UserAuth type. + * This thin adapter exists because domain cannot import @prisma/client directly. * - * NOTE: This is an infrastructure adapter - Prisma is BFF's ORM implementation detail. - * The domain provider handles the actual mapping logic. + * NOTE: UserAuth contains ONLY auth state. Profile data comes from WHMCS. + * For complete user profile, use UsersFacade.getProfile() which fetches from WHMCS. */ import type { User as PrismaUser } from "@prisma/client"; @@ -15,29 +16,8 @@ type PrismaUserRaw = Parameters[0]; /** * Maps Prisma User entity to Domain UserAuth type - * - * This adapter converts the @prisma/client User to the domain's PrismaUserRaw type, - * then uses the domain portal provider mapper to get UserAuth. - * - * NOTE: UserAuth contains ONLY auth state. Profile data comes from WHMCS. - * For complete user profile, use UsersFacade.getProfile() which fetches from WHMCS. */ export function mapPrismaUserToDomain(user: PrismaUser): UserAuth { - // Convert @prisma/client User to domain PrismaUserRaw - const prismaUserRaw: PrismaUserRaw = { - id: user.id, - email: user.email, - passwordHash: user.passwordHash, - role: user.role, - mfaSecret: user.mfaSecret, - emailVerified: user.emailVerified, - failedLoginAttempts: user.failedLoginAttempts, - lockedUntil: user.lockedUntil, - lastLoginAt: user.lastLoginAt, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - }; - - // Use domain provider mapper - return mapPrismaUserToUserAuth(prismaUserRaw); + // PrismaUser and PrismaUserRaw are structurally identical - cast and delegate + return mapPrismaUserToUserAuth(user as PrismaUserRaw); } diff --git a/apps/bff/src/modules/auth/application/auth.facade.ts b/apps/bff/src/modules/auth/application/auth.facade.ts index f0d62cd1..3e940142 100644 --- a/apps/bff/src/modules/auth/application/auth.facade.ts +++ b/apps/bff/src/modules/auth/application/auth.facade.ts @@ -25,9 +25,9 @@ import { PrismaService } from "@bff/infra/database/prisma.service.js"; import { TokenBlacklistService } from "../infra/token/token-blacklist.service.js"; import { AuthTokenService } from "../infra/token/token.service.js"; import { AuthRateLimitService } from "../infra/rate-limiting/auth-rate-limit.service.js"; -import { SignupWorkflowService } from "../infra/workflows/workflows/signup-workflow.service.js"; -import { PasswordWorkflowService } from "../infra/workflows/workflows/password-workflow.service.js"; -import { WhmcsLinkWorkflowService } from "../infra/workflows/workflows/whmcs-link-workflow.service.js"; +import { SignupWorkflowService } from "../infra/workflows/signup-workflow.service.js"; +import { PasswordWorkflowService } from "../infra/workflows/password-workflow.service.js"; +import { WhmcsLinkWorkflowService } from "../infra/workflows/whmcs-link-workflow.service.js"; import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js"; @Injectable() diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index aade25f9..0b593036 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -11,9 +11,9 @@ import { EmailModule } from "@bff/infra/email/email.module.js"; import { CacheModule } from "@bff/infra/cache/cache.module.js"; import { AuthTokenService } from "./infra/token/token.service.js"; import { JoseJwtService } from "./infra/token/jose-jwt.service.js"; -import { SignupWorkflowService } from "./infra/workflows/workflows/signup-workflow.service.js"; -import { PasswordWorkflowService } from "./infra/workflows/workflows/password-workflow.service.js"; -import { WhmcsLinkWorkflowService } from "./infra/workflows/workflows/whmcs-link-workflow.service.js"; +import { SignupWorkflowService } from "./infra/workflows/signup-workflow.service.js"; +import { PasswordWorkflowService } from "./infra/workflows/password-workflow.service.js"; +import { WhmcsLinkWorkflowService } from "./infra/workflows/whmcs-link-workflow.service.js"; import { FailedLoginThrottleGuard } from "./presentation/http/guards/failed-login-throttle.guard.js"; import { LoginResultInterceptor } from "./presentation/http/interceptors/login-result.interceptor.js"; import { AuthRateLimitService } from "./infra/rate-limiting/auth-rate-limit.service.js"; diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts similarity index 97% rename from apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts rename to apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts index 97698039..6dcaf501 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/password-workflow.service.ts @@ -13,9 +13,9 @@ import { UsersFacade } from "@bff/modules/users/application/users.facade.js"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js"; import { EmailService } from "@bff/infra/email/email.service.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; -import { AuthTokenService } from "../../token/token.service.js"; -import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service.js"; -import { JoseJwtService } from "../../token/jose-jwt.service.js"; +import { AuthTokenService } from "../token/token.service.js"; +import { AuthRateLimitService } from "../rate-limiting/auth-rate-limit.service.js"; +import { JoseJwtService } from "../token/jose-jwt.service.js"; import { type ChangePasswordRequest, changePasswordRequestSchema, diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/signup-workflow.service.ts similarity index 99% rename from apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts rename to apps/bff/src/modules/auth/infra/workflows/signup-workflow.service.ts index 9ab0c140..422a72a3 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/signup-workflow.service.ts @@ -17,8 +17,8 @@ import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/w import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js"; import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js"; import { PrismaService } from "@bff/infra/database/prisma.service.js"; -import { AuthTokenService } from "../../token/token.service.js"; -import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service.js"; +import { AuthTokenService } from "../token/token.service.js"; +import { AuthRateLimitService } from "../rate-limiting/auth-rate-limit.service.js"; import { getErrorMessage } from "@bff/core/utils/error.util.js"; import { DomainHttpException } from "@bff/core/http/domain-http.exception.js"; import { diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts similarity index 100% rename from apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts rename to apps/bff/src/modules/auth/infra/workflows/whmcs-link-workflow.service.ts diff --git a/apps/bff/src/modules/billing/billing.controller.ts b/apps/bff/src/modules/billing/billing.controller.ts index cb8f067e..7af77011 100644 --- a/apps/bff/src/modules/billing/billing.controller.ts +++ b/apps/bff/src/modules/billing/billing.controller.ts @@ -1,5 +1,15 @@ -import { Controller, Get, Post, Param, Query, Request, HttpCode, HttpStatus } from "@nestjs/common"; -import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service.js"; +import { + Controller, + Get, + Post, + Param, + Query, + Request, + HttpCode, + HttpStatus, + NotFoundException, +} from "@nestjs/common"; +import { InvoiceRetrievalService } from "./services/invoice-retrieval.service.js"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { createZodDto, ZodResponse } from "nestjs-zod"; @@ -15,7 +25,6 @@ import { invoiceSsoQuerySchema, invoicePaymentLinkQuerySchema, } from "@customer-portal/domain/billing"; -import type { Subscription } from "@customer-portal/domain/subscriptions"; import type { PaymentMethodList, PaymentGatewayList, @@ -47,7 +56,7 @@ class InvoicePaymentLinkDto extends createZodDto(invoicePaymentLinkSchema) {} @Controller("invoices") export class BillingController { constructor( - private readonly invoicesService: InvoicesOrchestratorService, + private readonly invoicesService: InvoiceRetrievalService, private readonly whmcsService: WhmcsService, private readonly mappingsService: MappingsService ) {} @@ -66,7 +75,7 @@ export class BillingController { async getPaymentMethods(@Request() req: RequestWithUser): Promise { const mapping = await this.mappingsService.findByUserId(req.user.id); if (!mapping?.whmcsClientId) { - throw new Error("WHMCS client mapping not found"); + throw new NotFoundException("WHMCS client mapping not found"); } return this.whmcsService.getPaymentMethods(mapping.whmcsClientId, req.user.id); } @@ -87,7 +96,7 @@ export class BillingController { // Return fresh payment methods const mapping = await this.mappingsService.findByUserId(req.user.id); if (!mapping?.whmcsClientId) { - throw new Error("WHMCS client mapping not found"); + throw new NotFoundException("WHMCS client mapping not found"); } return this.whmcsService.getPaymentMethods(mapping.whmcsClientId, req.user.id); } @@ -101,13 +110,6 @@ export class BillingController { return this.invoicesService.getInvoiceById(req.user.id, params.id); } - @Get(":id/subscriptions") - getInvoiceSubscriptions(): Subscription[] { - // This functionality has been moved to WHMCS directly - // For now, return empty array as subscriptions are managed in WHMCS - return []; - } - @Post(":id/sso-link") @HttpCode(HttpStatus.OK) @ZodResponse({ description: "Create invoice SSO link", type: InvoiceSsoLinkDto }) @@ -118,7 +120,7 @@ export class BillingController { ): Promise { const mapping = await this.mappingsService.findByUserId(req.user.id); if (!mapping?.whmcsClientId) { - throw new Error("WHMCS client mapping not found"); + throw new NotFoundException("WHMCS client mapping not found"); } const parsedQuery = invoiceSsoQuerySchema.parse(query as unknown); @@ -145,7 +147,7 @@ export class BillingController { ): Promise { const mapping = await this.mappingsService.findByUserId(req.user.id); if (!mapping?.whmcsClientId) { - throw new Error("WHMCS client mapping not found"); + throw new NotFoundException("WHMCS client mapping not found"); } const parsedQuery = invoicePaymentLinkQuerySchema.parse(query as unknown); diff --git a/apps/bff/src/modules/billing/billing.module.ts b/apps/bff/src/modules/billing/billing.module.ts index ea447a67..1c648668 100644 --- a/apps/bff/src/modules/billing/billing.module.ts +++ b/apps/bff/src/modules/billing/billing.module.ts @@ -2,21 +2,17 @@ import { Module } from "@nestjs/common"; import { BillingController } from "./billing.controller.js"; import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js"; -// New modular invoice services -import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service.js"; import { InvoiceRetrievalService } from "./services/invoice-retrieval.service.js"; -import { InvoiceHealthService } from "./services/invoice-health.service.js"; /** * Billing Module * * Validation is handled by Zod schemas via Zod DTOs + the global ZodValidationPipe (APP_PIPE). - * No separate validator service needed. */ @Module({ imports: [WhmcsModule, MappingsModule], controllers: [BillingController], - providers: [InvoicesOrchestratorService, InvoiceRetrievalService, InvoiceHealthService], - exports: [InvoicesOrchestratorService], + providers: [InvoiceRetrievalService], + exports: [InvoiceRetrievalService], }) export class BillingModule {} diff --git a/apps/bff/src/modules/billing/index.ts b/apps/bff/src/modules/billing/index.ts index 800a39bb..8e0bbd11 100644 --- a/apps/bff/src/modules/billing/index.ts +++ b/apps/bff/src/modules/billing/index.ts @@ -4,9 +4,4 @@ export * from "./billing.module.js"; export * from "./billing.controller.js"; -export * from "./services/invoices-orchestrator.service.js"; export * from "./services/invoice-retrieval.service.js"; -export * from "./services/invoice-health.service.js"; - -// Export monitoring types (infrastructure concerns) -export type { InvoiceHealthStatus, InvoiceServiceStats } from "./types/invoice-monitoring.types.js"; diff --git a/apps/bff/src/modules/billing/services/invoice-health.service.ts b/apps/bff/src/modules/billing/services/invoice-health.service.ts deleted file mode 100644 index df1bf31e..00000000 --- a/apps/bff/src/modules/billing/services/invoice-health.service.ts +++ /dev/null @@ -1,202 +0,0 @@ -import { Injectable, Inject } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; -import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; -import { getErrorMessage } from "@bff/core/utils/error.util.js"; -import type { - InvoiceHealthStatus, - InvoiceServiceStats, -} from "../types/invoice-monitoring.types.js"; - -/** - * Service responsible for health checks and monitoring of invoice services - */ -@Injectable() -export class InvoiceHealthService { - private stats: InvoiceServiceStats = { - totalInvoicesRetrieved: 0, - totalPaymentLinksCreated: 0, - totalSsoLinksCreated: 0, - averageResponseTime: 0, - }; - - constructor( - private readonly whmcsService: WhmcsService, - private readonly mappingsService: MappingsService, - @Inject(Logger) private readonly logger: Logger - ) {} - - /** - * Perform comprehensive health check - */ - async healthCheck(): Promise { - try { - const checks = await Promise.allSettled([ - this.checkWhmcsHealth(), - this.checkMappingsHealth(), - ]); - - const whmcsResult = checks[0]; - const mappingsResult = checks[1]; - - const isHealthy = - whmcsResult.status === "fulfilled" && - whmcsResult.value && - mappingsResult.status === "fulfilled" && - mappingsResult.value; - - return { - status: isHealthy ? "healthy" : "unhealthy", - details: { - whmcsApi: - whmcsResult.status === "fulfilled" && whmcsResult.value ? "connected" : "disconnected", - mappingsService: - mappingsResult.status === "fulfilled" && mappingsResult.value - ? "available" - : "unavailable", - timestamp: new Date().toISOString(), - }, - }; - } catch (error) { - this.logger.error("Invoice service health check failed", { - error: getErrorMessage(error), - }); - - return { - status: "unhealthy", - details: { - error: getErrorMessage(error), - timestamp: new Date().toISOString(), - }, - }; - } - } - - /** - * Get service statistics - */ - getStats(): InvoiceServiceStats { - return { ...this.stats }; - } - - /** - * Reset service statistics - */ - resetStats(): void { - this.stats = { - totalInvoicesRetrieved: 0, - totalPaymentLinksCreated: 0, - totalSsoLinksCreated: 0, - averageResponseTime: 0, - }; - } - - /** - * Record invoice retrieval - */ - recordInvoiceRetrieval(responseTime: number): void { - this.stats.totalInvoicesRetrieved++; - this.updateAverageResponseTime(responseTime); - this.stats.lastRequestTime = new Date(); - } - - /** - * Record payment link creation - */ - recordPaymentLinkCreation(responseTime: number): void { - this.stats.totalPaymentLinksCreated++; - this.updateAverageResponseTime(responseTime); - this.stats.lastRequestTime = new Date(); - } - - /** - * Record SSO link creation - */ - recordSsoLinkCreation(responseTime: number): void { - this.stats.totalSsoLinksCreated++; - this.updateAverageResponseTime(responseTime); - this.stats.lastRequestTime = new Date(); - } - - /** - * Record error - */ - recordError(): void { - this.stats.lastErrorTime = new Date(); - } - - /** - * Check WHMCS service health - */ - private async checkWhmcsHealth(): Promise { - try { - return await this.whmcsService.healthCheck(); - } catch (error) { - this.logger.warn("WHMCS health check failed", { - error: getErrorMessage(error), - }); - return false; - } - } - - /** - * Check mappings service health - */ - private async checkMappingsHealth(): Promise { - try { - // Simple check to see if mappings service is responsive - // We don't want to create test data, so we'll just check if the service responds - await this.mappingsService.findByUserId("health-check-test"); - return true; - } catch (error) { - // We expect this to fail for a non-existent user, but if the service responds, it's healthy - const errorMessage = getErrorMessage(error); - - // If it's a "not found" error, the service is working - if (errorMessage.toLowerCase().includes("not found")) { - return true; - } - - this.logger.warn("Mappings service health check failed", { - error: errorMessage, - }); - return false; - } - } - - /** - * Update average response time - */ - private updateAverageResponseTime(responseTime: number): void { - const totalRequests = - this.stats.totalInvoicesRetrieved + - this.stats.totalPaymentLinksCreated + - this.stats.totalSsoLinksCreated; - - if (totalRequests === 1) { - this.stats.averageResponseTime = responseTime; - } else { - this.stats.averageResponseTime = - (this.stats.averageResponseTime * (totalRequests - 1) + responseTime) / totalRequests; - } - } - - /** - * Get health summary - */ - async getHealthSummary(): Promise<{ - status: string; - uptime: number; - stats: InvoiceServiceStats; - lastCheck: string; - }> { - const health = await this.healthCheck(); - - return { - status: health.status, - uptime: process.uptime(), - stats: this.getStats(), - lastCheck: new Date().toISOString(), - }; - } -} diff --git a/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts b/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts index d3457364..4a80f41b 100644 --- a/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts +++ b/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts @@ -5,7 +5,6 @@ import { Inject, } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { invoiceSchema, invoiceListQuerySchema } from "@customer-portal/domain/billing"; import type { Invoice, InvoiceList, @@ -33,13 +32,9 @@ export class InvoiceRetrievalService { /** * Get paginated invoices for a user - * @param userId - User ID (should be validated by controller) - * @param options - Query options (should be validated by controller using invoiceListQuerySchema) */ async getInvoices(userId: string, options: InvoiceListQuery = {}): Promise { - // Validate options against schema for internal calls - const validatedOptions = invoiceListQuerySchema.parse(options); - const { page = 1, limit = 10, status } = validatedOptions; + const { page = 1, limit = 10, status } = options; try { // Get user mapping @@ -76,15 +71,9 @@ export class InvoiceRetrievalService { /** * Get individual invoice by ID - * @param userId - User ID (should be validated by controller) - * @param invoiceId - Invoice ID (should be validated by controller/schema) */ async getInvoiceById(userId: string, invoiceId: number): Promise { try { - // Validate invoice ID using schema - invoiceSchema.shape.id.parse(invoiceId); - - // Get user mapping const mapping = await this.getUserMapping(userId); // Fetch invoice from WHMCS diff --git a/apps/bff/src/modules/billing/services/invoices-orchestrator.service.ts b/apps/bff/src/modules/billing/services/invoices-orchestrator.service.ts deleted file mode 100644 index d3c7ff61..00000000 --- a/apps/bff/src/modules/billing/services/invoices-orchestrator.service.ts +++ /dev/null @@ -1,206 +0,0 @@ -import { Injectable, Inject } from "@nestjs/common"; -import { Logger } from "nestjs-pino"; -import { INVOICE_PAGINATION, VALID_INVOICE_STATUSES } from "@customer-portal/domain/billing"; -import type { - Invoice, - InvoiceList, - InvoiceListQuery, - InvoiceStatus, -} from "@customer-portal/domain/billing"; -import { InvoiceRetrievalService } from "./invoice-retrieval.service.js"; -import { InvoiceHealthService } from "./invoice-health.service.js"; -import type { - InvoiceHealthStatus, - InvoiceServiceStats, -} from "../types/invoice-monitoring.types.js"; - -/** - * Main orchestrator service for invoice operations - * Coordinates all invoice-related services and provides a unified interface - */ -@Injectable() -export class InvoicesOrchestratorService { - constructor( - private readonly retrievalService: InvoiceRetrievalService, - private readonly healthService: InvoiceHealthService, - @Inject(Logger) private readonly logger: Logger - ) {} - - // ========================================== - // INVOICE RETRIEVAL METHODS - // ========================================== - - /** - * Get paginated invoices for a user - */ - async getInvoices(userId: string, options: InvoiceListQuery = {}): Promise { - const startTime = Date.now(); - - try { - const result = await this.retrievalService.getInvoices(userId, options); - this.healthService.recordInvoiceRetrieval(Date.now() - startTime); - return result; - } catch (error) { - this.healthService.recordError(); - throw error; - } - } - - /** - * Get individual invoice by ID - */ - async getInvoiceById(userId: string, invoiceId: number): Promise { - const startTime = Date.now(); - - try { - const result = await this.retrievalService.getInvoiceById(userId, invoiceId); - this.healthService.recordInvoiceRetrieval(Date.now() - startTime); - return result; - } catch (error) { - this.healthService.recordError(); - throw error; - } - } - - /** - * Get invoices by status - */ - async getInvoicesByStatus( - userId: string, - status: InvoiceStatus, - options: Partial = {} - ): Promise { - const startTime = Date.now(); - - try { - const result = await this.retrievalService.getInvoicesByStatus(userId, status, options); - this.healthService.recordInvoiceRetrieval(Date.now() - startTime); - return result; - } catch (error) { - this.healthService.recordError(); - throw error; - } - } - - /** - * Get unpaid invoices for a user - */ - async getUnpaidInvoices( - userId: string, - options: Partial = {} - ): Promise { - return this.retrievalService.getUnpaidInvoices(userId, options); - } - - /** - * Get overdue invoices for a user - */ - async getOverdueInvoices( - userId: string, - options: Partial = {} - ): Promise { - return this.retrievalService.getOverdueInvoices(userId, options); - } - - /** - * Get paid invoices for a user - */ - async getPaidInvoices( - userId: string, - options: Partial = {} - ): Promise { - return this.retrievalService.getPaidInvoices(userId, options); - } - - /** - * Get cancelled invoices for a user - */ - async getCancelledInvoices( - userId: string, - options: Partial = {} - ): Promise { - return this.retrievalService.getCancelledInvoices(userId, options); - } - - /** - * Get invoices in collections for a user - */ - async getCollectionsInvoices( - userId: string, - options: Partial = {} - ): Promise { - return this.retrievalService.getCollectionsInvoices(userId, options); - } - - // ========================================== - // INVOICE OPERATIONS METHODS - // ========================================== - - // ========================================== - // UTILITY METHODS - // ========================================== - - /** - * Check if user has any invoices - */ - async hasInvoices(userId: string): Promise { - return this.retrievalService.hasInvoices(userId); - } - - /** - * Get invoice count by status - */ - async getInvoiceCountByStatus(userId: string, status: InvoiceStatus): Promise { - return this.retrievalService.getInvoiceCountByStatus(userId, status); - } - - /** - * Health check for invoice service - */ - async healthCheck(): Promise { - return this.healthService.healthCheck(); - } - - /** - * Get service statistics - */ - getServiceStats(): InvoiceServiceStats { - return this.healthService.getStats(); - } - - /** - * Reset service statistics - */ - resetServiceStats(): void { - this.healthService.resetStats(); - } - - /** - * Get health summary - */ - async getHealthSummary(): Promise<{ - status: string; - uptime: number; - stats: InvoiceServiceStats; - lastCheck: string; - }> { - return this.healthService.getHealthSummary(); - } - - /** - * Get valid invoice statuses (from domain) - */ - getValidStatuses(): readonly InvoiceStatus[] { - return VALID_INVOICE_STATUSES; - } - - /** - * Get pagination limits (from domain) - */ - getPaginationLimits(): { min: number; max: number } { - return { - min: INVOICE_PAGINATION.MIN_LIMIT, - max: INVOICE_PAGINATION.MAX_LIMIT, - }; - } -} diff --git a/apps/bff/src/modules/billing/types/invoice-monitoring.types.ts b/apps/bff/src/modules/billing/types/invoice-monitoring.types.ts deleted file mode 100644 index 7d40e236..00000000 --- a/apps/bff/src/modules/billing/types/invoice-monitoring.types.ts +++ /dev/null @@ -1,26 +0,0 @@ -/** - * BFF Invoice Monitoring Types - * - * Infrastructure types for monitoring, health checks, and statistics. - * These are BFF-specific and do not belong in the domain layer. - */ - -// Infrastructure monitoring types -export interface InvoiceServiceStats { - totalInvoicesRetrieved: number; - totalPaymentLinksCreated: number; - totalSsoLinksCreated: number; - averageResponseTime: number; - lastRequestTime?: Date; - lastErrorTime?: Date; -} - -export interface InvoiceHealthStatus { - status: "healthy" | "unhealthy"; - details: { - whmcsApi?: string; - mappingsService?: string; - error?: string; - timestamp: string; - }; -} diff --git a/packages/domain/common/providers/whmcs-utils/custom-fields.ts b/packages/domain/common/providers/whmcs-utils/custom-fields.ts new file mode 100644 index 00000000..863fa260 --- /dev/null +++ b/packages/domain/common/providers/whmcs-utils/custom-fields.ts @@ -0,0 +1,84 @@ +/** + * WHMCS Custom Field Utilities (domain-internal) + */ + +const isObject = (value: unknown): value is Record => + typeof value === "object" && value !== null; + +const normalizeCustomFieldEntries = (value: unknown): Array> => { + if (Array.isArray(value)) return value.filter(isObject); + if (isObject(value) && "customfield" in value) { + const custom = (value as { customfield?: unknown }).customfield; + if (Array.isArray(custom)) return custom.filter(isObject); + if (isObject(custom)) return [custom]; + return []; + } + return []; +}; + +/** + * Build a lightweight map of WHMCS custom field identifiers to values. + * Accepts the documented WHMCS response shapes (array or { customfield }). + */ +export function getCustomFieldsMap(customFields: unknown): Record { + if (!customFields) return {}; + + if (isObject(customFields) && !Array.isArray(customFields) && !("customfield" in customFields)) { + return Object.entries(customFields).reduce>((acc, [key, value]) => { + if (typeof value === "string") { + const trimmedKey = key.trim(); + if (trimmedKey) acc[trimmedKey] = value; + } + return acc; + }, {}); + } + + const map: Record = {}; + for (const entry of normalizeCustomFieldEntries(customFields)) { + const idRaw = "id" in entry ? entry.id : undefined; + const id = + typeof idRaw === "string" + ? idRaw.trim() + : typeof idRaw === "number" + ? String(idRaw) + : undefined; + const name = "name" in entry && typeof entry.name === "string" ? entry.name.trim() : undefined; + const rawValue = "value" in entry ? entry.value : undefined; + if (rawValue === undefined || rawValue === null) continue; + const value = + typeof rawValue === "string" + ? rawValue + : typeof rawValue === "number" || typeof rawValue === "boolean" + ? String(rawValue) + : undefined; + if (!value) continue; + + if (id) map[id] = value; + if (name) map[name] = value; + } + + return map; +} + +/** + * Retrieve a custom field value by numeric id or name. + */ +export function getCustomFieldValue( + customFields: unknown, + key: string | number +): string | undefined { + if (key === undefined || key === null) return undefined; + const map = getCustomFieldsMap(customFields); + const primary = map[String(key)]; + if (primary !== undefined) return primary; + + if (typeof key === "string") { + const numeric = Number.parseInt(key, 10); + if (!Number.isNaN(numeric)) { + const numericValue = map[String(numeric)]; + if (numericValue !== undefined) return numericValue; + } + } + + return undefined; +} diff --git a/packages/domain/common/providers/whmcs-utils/encoding.ts b/packages/domain/common/providers/whmcs-utils/encoding.ts new file mode 100644 index 00000000..cf702b7f --- /dev/null +++ b/packages/domain/common/providers/whmcs-utils/encoding.ts @@ -0,0 +1,79 @@ +/** + * Encoding utilities for WHMCS helpers (domain-internal) + * + * Avoid hard Node `Buffer` dependency. Use it when available; otherwise fall back + * to pure JS UTF-8 + base64. + */ + +const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + +function encodeUtf8Fallback(value: string): Uint8Array { + const bytes: number[] = []; + for (let i = 0; i < value.length; i++) { + let codePoint = value.charCodeAt(i); + + // Handle surrogate pairs + if (codePoint >= 0xd800 && codePoint <= 0xdbff && i + 1 < value.length) { + const next = value.charCodeAt(i + 1); + if (next >= 0xdc00 && next <= 0xdfff) { + codePoint = ((codePoint - 0xd800) << 10) + (next - 0xdc00) + 0x10000; + i++; + } + } + + if (codePoint <= 0x7f) { + bytes.push(codePoint); + } else if (codePoint <= 0x7ff) { + bytes.push(0xc0 | (codePoint >> 6)); + bytes.push(0x80 | (codePoint & 0x3f)); + } else if (codePoint <= 0xffff) { + bytes.push(0xe0 | (codePoint >> 12)); + bytes.push(0x80 | ((codePoint >> 6) & 0x3f)); + bytes.push(0x80 | (codePoint & 0x3f)); + } else { + bytes.push(0xf0 | (codePoint >> 18)); + bytes.push(0x80 | ((codePoint >> 12) & 0x3f)); + bytes.push(0x80 | ((codePoint >> 6) & 0x3f)); + bytes.push(0x80 | (codePoint & 0x3f)); + } + } + return new Uint8Array(bytes); +} + +function encodeUtf8(value: string): Uint8Array { + if (typeof TextEncoder !== "undefined") { + return new TextEncoder().encode(value); + } + return encodeUtf8Fallback(value); +} + +export function byteLengthUtf8(value: string): number { + if (typeof Buffer !== "undefined") { + return Buffer.byteLength(value, "utf8"); + } + return encodeUtf8(value).length; +} + +function base64EncodeBytes(bytes: Uint8Array): string { + let out = ""; + for (let i = 0; i < bytes.length; i += 3) { + const b1 = bytes[i] ?? 0; + const b2 = bytes[i + 1] ?? 0; + const b3 = bytes[i + 2] ?? 0; + + const triplet = (b1 << 16) | (b2 << 8) | b3; + + out += BASE64_ALPHABET[(triplet >> 18) & 0x3f]; + out += BASE64_ALPHABET[(triplet >> 12) & 0x3f]; + out += i + 1 < bytes.length ? BASE64_ALPHABET[(triplet >> 6) & 0x3f] : "="; + out += i + 2 < bytes.length ? BASE64_ALPHABET[triplet & 0x3f] : "="; + } + return out; +} + +export function utf8ToBase64(value: string): string { + if (typeof Buffer !== "undefined") { + return Buffer.from(value, "utf8").toString("base64"); + } + return base64EncodeBytes(encodeUtf8(value)); +} diff --git a/packages/domain/common/providers/whmcs-utils/index.ts b/packages/domain/common/providers/whmcs-utils/index.ts new file mode 100644 index 00000000..33db5401 --- /dev/null +++ b/packages/domain/common/providers/whmcs-utils/index.ts @@ -0,0 +1,12 @@ +/** + * WHMCS shared provider helpers (domain-internal) + * + * Intentionally NOT exported from `@customer-portal/domain/common/providers` to avoid + * expanding the app-facing API surface. This module is used by domain mappers via + * an internal alias. + */ + +export * from "./parsing.js"; +export * from "./normalize.js"; +export * from "./custom-fields.js"; +export * from "./php-serialize.js"; diff --git a/packages/domain/common/providers/whmcs-utils/normalize.ts b/packages/domain/common/providers/whmcs-utils/normalize.ts new file mode 100644 index 00000000..55f93125 --- /dev/null +++ b/packages/domain/common/providers/whmcs-utils/normalize.ts @@ -0,0 +1,34 @@ +/** + * WHMCS Normalization Utilities (domain-internal) + */ + +/** + * Normalize status using provided status map. + * Generic helper for consistent status mapping. + */ +export function normalizeStatus( + status: string | null | undefined, + statusMap: Record, + defaultStatus: T +): T { + if (!status) return defaultStatus; + const mapped = statusMap[status.trim().toLowerCase()]; + return mapped ?? defaultStatus; +} + +/** + * Normalize billing cycle using provided cycle map. + * Generic helper for consistent cycle mapping. + */ +export function normalizeCycle( + cycle: string | null | undefined, + cycleMap: Record, + defaultCycle: T +): T { + if (!cycle) return defaultCycle; + const normalized = cycle + .trim() + .toLowerCase() + .replace(/[_\s-]+/g, " "); + return cycleMap[normalized] ?? defaultCycle; +} diff --git a/packages/domain/common/providers/whmcs-utils/parsing.ts b/packages/domain/common/providers/whmcs-utils/parsing.ts new file mode 100644 index 00000000..2c300208 --- /dev/null +++ b/packages/domain/common/providers/whmcs-utils/parsing.ts @@ -0,0 +1,29 @@ +/** + * WHMCS Parsing Utilities (domain-internal) + */ + +/** + * Parse amount from WHMCS API response. + * WHMCS returns amounts as strings or numbers. + */ +export function parseAmount(amount: string | number | undefined): number { + if (typeof amount === "number") return amount; + if (!amount) return 0; + + const cleaned = String(amount).replace(/[^\d.-]/g, ""); + const parsed = Number.parseFloat(cleaned); + return Number.isNaN(parsed) ? 0 : parsed; +} + +/** + * Format date from WHMCS API to ISO string. + * Returns undefined if input is invalid. + */ +export function formatDate(input?: string | null): string | undefined { + if (!input) return undefined; + + const date = new Date(input); + if (Number.isNaN(date.getTime())) return undefined; + + return date.toISOString(); +} diff --git a/packages/domain/common/providers/whmcs-utils/php-serialize.ts b/packages/domain/common/providers/whmcs-utils/php-serialize.ts new file mode 100644 index 00000000..c585e77e --- /dev/null +++ b/packages/domain/common/providers/whmcs-utils/php-serialize.ts @@ -0,0 +1,34 @@ +/** + * WHMCS PHP Serialization Utilities (domain-internal) + */ + +import { byteLengthUtf8, utf8ToBase64 } from "./encoding.js"; + +/** + * Serialize a key/value map into the format WHMCS expects for request parameters like `customfields`. + * + * Official docs: + * - AddClient: customfields = "Base64 encoded serialized array of custom field values." + * @see https://developers.whmcs.com/api-reference/addclient/ + */ +export function serializeWhmcsKeyValueMap(data?: Record): string { + if (!data) return ""; + const entries = Object.entries(data).filter(([k]) => String(k).trim().length > 0); + if (entries.length === 0) return ""; + + const serializedEntries = entries.map(([key, value]) => { + const safeKey = key ?? ""; + const safeValue = value ?? ""; + return ( + `s:${byteLengthUtf8(safeKey)}:"${escapePhpString(safeKey)}";` + + `s:${byteLengthUtf8(safeValue)}:"${escapePhpString(safeValue)}";` + ); + }); + + const serialized = `a:${serializedEntries.length}:{${serializedEntries.join("")}}`; + return utf8ToBase64(serialized); +} + +function escapePhpString(value: string): string { + return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"'); +}