From 065e2f9acfeec6c4212df9a0127d5870b4d2c182 Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 25 Sep 2025 15:54:54 +0900 Subject: [PATCH] Refactor import paths for AlertBanner, AsyncBlock, and other components in the portal to enhance module structure and maintainability. Remove unused components and streamline imports across various views and features, ensuring consistent file organization. --- .../integrations/freebit/freebit.service.ts | 3 +- .../integrations/freebit/services/index.ts | 9 +- .../connection/config/whmcs-config.service.ts | 7 + .../integrations/whmcs/connection/index.ts | 16 - .../services/whmcs-api-methods.service.ts | 62 +- .../whmcs-connection-orchestrator.service.ts | 62 +- .../whmcs/services/whmcs-invoice.service.ts | 221 ------- .../integrations/whmcs/transformers/index.ts | 14 - .../services/invoice-transformer.service.ts | 12 +- .../services/payment-transformer.service.ts | 59 +- .../whmcs/types/whmcs-api.types.ts | 142 ----- .../src/integrations/whmcs/whmcs.service.ts | 47 -- apps/bff/src/modules/invoices/index.ts | 22 + .../src/modules/invoices/invoices.module.ts | 20 +- .../src/modules/invoices/invoices.service.ts | 599 ++++-------------- .../services/invoice-health.service.ts | 193 ++++++ .../services/invoice-retrieval.service.ts | 221 +++++++ .../services/invoices-orchestrator.service.ts | 206 ++++++ .../invoices/types/invoice-service.types.ts | 41 ++ .../validators/invoice-validator.service.ts | 164 +++++ apps/portal/src/app/(public)/auth/loading.tsx | 2 +- .../components/molecules/AlertBanner/index.ts | 1 - .../components/molecules/AsyncBlock/index.ts | 1 - .../molecules/DetailHeader/index.ts | 1 - .../molecules/PaginationBar/index.ts | 1 - .../molecules/ProgressSteps/ProgressSteps.tsx | 2 +- apps/portal/src/components/molecules/index.ts | 8 +- .../account/components/PersonalInfoCard.tsx | 6 +- .../account/views/ProfileContainer.tsx | 2 +- .../auth/components/LinkWhmcsForm/index.ts | 1 - .../auth/components/LoginForm/index.ts | 1 - .../components/PasswordResetForm/index.ts | 1 - .../auth/components/SetPasswordForm/index.ts | 1 - .../src/features/auth/components/index.ts | 10 +- .../components/BillingStatusBadge/index.ts | 1 - .../components/BillingSummary/index.ts | 1 - .../InvoiceDetail/InvoiceHeader.tsx | 2 +- .../components/InvoiceList/InvoiceList.tsx | 4 +- .../billing/components/InvoiceList/index.ts | 1 - .../billing/components/InvoiceTable/index.ts | 1 - .../components/PaymentMethodCard/index.ts | 1 - .../features/billing/views/InvoiceDetail.tsx | 2 +- .../features/billing/views/PaymentMethods.tsx | 2 +- .../catalog/components/base/AddonGroup.tsx | 11 +- .../components/base/AddressConfirmation.tsx | 2 +- .../components/base/ConfigurationStep.tsx | 2 +- .../catalog/components/base/PaymentForm.tsx | 2 +- .../components/base/ProductComparison.tsx | 2 +- .../catalog/components/common/FeatureCard.tsx | 2 +- .../components/common/ServiceHeroCard.tsx | 2 +- .../catalog/components/common/index.ts | 2 - .../steps/ServiceConfigurationStep.tsx | 2 +- .../catalog/components/internet/index.ts | 1 - .../catalog/components/sim/SimPlanCard.tsx | 2 +- .../features/catalog/components/vpn/index.ts | 1 - .../src/features/catalog/utils/index.ts | 1 - .../features/catalog/views/InternetPlans.tsx | 4 +- .../src/features/catalog/views/SimPlans.tsx | 2 +- .../src/features/catalog/views/VpnPlans.tsx | 4 +- .../checkout/views/CheckoutContainer.tsx | 4 +- .../components/PaymentErrorBanner.tsx | 2 +- .../src/features/orders/components/index.ts | 4 - .../src/features/orders/views/OrdersList.tsx | 2 +- .../subscriptions/views/SimChangePlan.tsx | 2 +- .../features/subscriptions/views/SimTopUp.tsx | 2 +- .../views/SubscriptionDetail.tsx | 2 +- .../subscriptions/views/SubscriptionsList.tsx | 2 +- .../domain/src/validation/forms/profile.ts | 9 + packages/domain/src/validation/index.ts | 19 + .../src/validation/shared/primitives.ts | 6 + 70 files changed, 1165 insertions(+), 1104 deletions(-) delete mode 100644 apps/bff/src/integrations/whmcs/connection/index.ts delete mode 100644 apps/bff/src/integrations/whmcs/transformers/index.ts create mode 100644 apps/bff/src/modules/invoices/index.ts create mode 100644 apps/bff/src/modules/invoices/services/invoice-health.service.ts create mode 100644 apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts create mode 100644 apps/bff/src/modules/invoices/services/invoices-orchestrator.service.ts create mode 100644 apps/bff/src/modules/invoices/types/invoice-service.types.ts create mode 100644 apps/bff/src/modules/invoices/validators/invoice-validator.service.ts delete mode 100644 apps/portal/src/components/molecules/AlertBanner/index.ts delete mode 100644 apps/portal/src/components/molecules/AsyncBlock/index.ts delete mode 100644 apps/portal/src/components/molecules/DetailHeader/index.ts delete mode 100644 apps/portal/src/components/molecules/PaginationBar/index.ts delete mode 100644 apps/portal/src/features/auth/components/LinkWhmcsForm/index.ts delete mode 100644 apps/portal/src/features/auth/components/LoginForm/index.ts delete mode 100644 apps/portal/src/features/auth/components/PasswordResetForm/index.ts delete mode 100644 apps/portal/src/features/auth/components/SetPasswordForm/index.ts delete mode 100644 apps/portal/src/features/billing/components/BillingStatusBadge/index.ts delete mode 100644 apps/portal/src/features/billing/components/BillingSummary/index.ts delete mode 100644 apps/portal/src/features/billing/components/InvoiceList/index.ts delete mode 100644 apps/portal/src/features/billing/components/InvoiceTable/index.ts delete mode 100644 apps/portal/src/features/billing/components/PaymentMethodCard/index.ts delete mode 100644 apps/portal/src/features/catalog/components/common/index.ts delete mode 100644 apps/portal/src/features/catalog/components/internet/index.ts delete mode 100644 apps/portal/src/features/catalog/components/vpn/index.ts delete mode 100644 apps/portal/src/features/catalog/utils/index.ts delete mode 100644 apps/portal/src/features/orders/components/index.ts diff --git a/apps/bff/src/integrations/freebit/freebit.service.ts b/apps/bff/src/integrations/freebit/freebit.service.ts index cb051fa5..e8bdc53d 100644 --- a/apps/bff/src/integrations/freebit/freebit.service.ts +++ b/apps/bff/src/integrations/freebit/freebit.service.ts @@ -5,6 +5,7 @@ import type { SimUsage, SimTopUpHistory, } from "./interfaces/freebit.types"; +import type { MnpData } from "@customer-portal/domain"; @Injectable() export class FreebitService { @@ -116,7 +117,7 @@ export class FreebitService { simType: "eSIM" | "Physical SIM"; activationType: "Immediate" | "Scheduled"; scheduledAt?: string; - mnp?: any; + mnp?: MnpData; }): Promise { // For eSIM, use the enhanced reissue method if (params.simType === "eSIM") { diff --git a/apps/bff/src/integrations/freebit/services/index.ts b/apps/bff/src/integrations/freebit/services/index.ts index f2ee4f61..4e6098c1 100644 --- a/apps/bff/src/integrations/freebit/services/index.ts +++ b/apps/bff/src/integrations/freebit/services/index.ts @@ -1,7 +1,4 @@ // Export all Freebit services -export { FreebitAuthService } from "./freebit-auth.service"; -export { FreebitClientService } from "./freebit-client.service"; -export { FreebitMapperService } from "./freebit-mapper.service"; -export { FreebitOperationsService } from "./freebit-operations.service"; -export { FreebitOrchestratorService } from "./freebit-orchestrator.service"; -export { FreebitError } from "./freebit-error.service"; +export { FreebitOrchestratorService } from './freebit-orchestrator.service'; +export { FreebitMapperService } from './freebit-mapper.service'; +export { FreebitOperationsService } from './freebit-operations.service'; diff --git a/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts b/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts index 2989ec78..091ea31f 100644 --- a/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts @@ -30,6 +30,13 @@ export class WhmcsConfigService { return this.accessKey; } + /** + * Get the base URL for WHMCS API + */ + getBaseUrl(): string { + return this.config.baseUrl; + } + /** * Check if admin authentication is available */ diff --git a/apps/bff/src/integrations/whmcs/connection/index.ts b/apps/bff/src/integrations/whmcs/connection/index.ts deleted file mode 100644 index 170152d8..00000000 --- a/apps/bff/src/integrations/whmcs/connection/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -// Main orchestrator service -export { WhmcsConnectionOrchestratorService } from "./services/whmcs-connection-orchestrator.service"; - -// Individual services -export { WhmcsConfigService } from "./config/whmcs-config.service"; -export { WhmcsHttpClientService } from "./services/whmcs-http-client.service"; -export { WhmcsErrorHandlerService } from "./services/whmcs-error-handler.service"; -export { WhmcsApiMethodsService } from "./services/whmcs-api-methods.service"; - -// Types -export type { - WhmcsApiConfig, - WhmcsRequestOptions, - WhmcsRetryConfig, - WhmcsConnectionStats, -} from "./types/connection.types"; diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts index 56864b99..537f556a 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts @@ -9,25 +9,13 @@ import type { WhmcsAddClientResponse, WhmcsCatalogProductsResponse, WhmcsPayMethodsResponse, - WhmcsAddPayMethodResponse, WhmcsPaymentGatewaysResponse, - WhmcsCreateInvoiceResponse, - WhmcsUpdateInvoiceResponse, - WhmcsCapturePaymentResponse, - WhmcsAddCreditResponse, - WhmcsAddInvoicePaymentResponse, WhmcsGetInvoicesParams, WhmcsGetClientsProductsParams, WhmcsCreateSsoTokenParams, WhmcsValidateLoginParams, WhmcsAddClientParams, WhmcsGetPayMethodsParams, - WhmcsAddPayMethodParams, - WhmcsCreateInvoiceParams, - WhmcsUpdateInvoiceParams, - WhmcsCapturePaymentParams, - WhmcsAddCreditParams, - WhmcsAddInvoicePaymentParams, } from "../../types/whmcs-api.types"; import { WhmcsHttpClientService } from "./whmcs-http-client.service"; import { WhmcsConfigService } from "../config/whmcs-config.service"; @@ -122,13 +110,6 @@ export class WhmcsApiMethodsService { return this.makeRequest("GetInvoice", { invoiceid: invoiceId }); } - async createInvoice(params: WhmcsCreateInvoiceParams): Promise { - return this.makeRequest("CreateInvoice", params); - } - - async updateInvoice(params: WhmcsUpdateInvoiceParams): Promise { - return this.makeRequest("UpdateInvoice", params); - } // ========================================== // PRODUCT/SUBSCRIPTION API METHODS @@ -150,27 +131,11 @@ export class WhmcsApiMethodsService { return this.makeRequest("GetPayMethods", params); } - async addPaymentMethod(params: WhmcsAddPayMethodParams): Promise { - return this.makeRequest("AddPayMethod", params); - } async getPaymentGateways(): Promise { return this.makeRequest("GetPaymentMethods", {}); } - async capturePayment(params: WhmcsCapturePaymentParams): Promise { - return this.makeRequest("CapturePayment", params); - } - - async addCredit(params: WhmcsAddCreditParams): Promise { - return this.makeRequest("AddCredit", params); - } - - async addInvoicePayment( - params: WhmcsAddInvoicePaymentParams - ): Promise { - return this.makeRequest("AddInvoicePayment", params); - } // ========================================== // SSO API METHODS @@ -180,32 +145,9 @@ export class WhmcsApiMethodsService { return this.makeRequest("CreateSsoToken", params); } - // ========================================== - // ADMIN API METHODS (require admin auth) - // ========================================== - async acceptOrder(orderId: number): Promise<{ result: string }> { - if (!this.configService.hasAdminAuth()) { - throw new Error("Admin authentication required for AcceptOrder"); - } - - return this.makeRequest<{ result: string }>( - "AcceptOrder", - { orderid: orderId }, - { useAdminAuth: true } - ); - } - - async cancelOrder(orderId: number): Promise<{ result: string }> { - if (!this.configService.hasAdminAuth()) { - throw new Error("Admin authentication required for CancelOrder"); - } - - return this.makeRequest<{ result: string }>( - "CancelOrder", - { orderid: orderId }, - { useAdminAuth: true } - ); + async getProducts() { + return this.makeRequest("GetProducts", {}); } // ========================================== diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts index 3b334b04..574f7f99 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts @@ -7,7 +7,13 @@ import { WhmcsErrorHandlerService } from "./whmcs-error-handler.service"; import { WhmcsApiMethodsService } from "./whmcs-api-methods.service"; import type { WhmcsApiResponse, - WhmcsErrorResponse + WhmcsErrorResponse, + WhmcsAddClientParams, + WhmcsValidateLoginParams, + WhmcsGetInvoicesParams, + WhmcsGetClientsProductsParams, + WhmcsGetPayMethodsParams, + WhmcsCreateSsoTokenParams, } from "../../types/whmcs-api.types"; import type { WhmcsRequestOptions, @@ -36,7 +42,7 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { // Test connection const isAvailable = await this.apiMethods.isAvailable(); if (isAvailable) { - this.logger.info("WHMCS connection established successfully"); + this.logger.log("WHMCS connection established successfully"); } else { this.logger.warn("WHMCS connection test failed - service may be unavailable"); } @@ -108,15 +114,15 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { return this.apiMethods.getClientDetailsByEmail(email); } - async updateClient(clientId: number, updateData: any) { + async updateClient(clientId: number, updateData: Record) { return this.apiMethods.updateClient(clientId, updateData); } - async addClient(params: any) { + async addClient(params: WhmcsAddClientParams) { return this.apiMethods.addClient(params); } - async validateLogin(params: any) { + async validateLogin(params: WhmcsValidateLoginParams) { return this.apiMethods.validateLogin(params); } @@ -124,7 +130,7 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { // INVOICE API METHODS // ========================================== - async getInvoices(params: any = {}) { + async getInvoices(params: WhmcsGetInvoicesParams = {}) { return this.apiMethods.getInvoices(params); } @@ -132,19 +138,12 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { return this.apiMethods.getInvoice(invoiceId); } - async createInvoice(params: any) { - return this.apiMethods.createInvoice(params); - } - - async updateInvoice(params: any) { - return this.apiMethods.updateInvoice(params); - } // ========================================== // PRODUCT/SUBSCRIPTION API METHODS // ========================================== - async getClientsProducts(params: any) { + async getClientsProducts(params: WhmcsGetClientsProductsParams) { return this.apiMethods.getClientsProducts(params); } @@ -152,53 +151,44 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { return this.apiMethods.getCatalogProducts(); } + + async getProducts() { + return this.apiMethods.getProducts(); + } + // ========================================== // PAYMENT API METHODS // ========================================== - async getPaymentMethods(params: any) { + async getPaymentMethods(params: WhmcsGetPayMethodsParams) { return this.apiMethods.getPaymentMethods(params); } - async addPaymentMethod(params: any) { - return this.apiMethods.addPaymentMethod(params); + // Legacy method name for backward compatibility + async getPayMethods(params: WhmcsGetPayMethodsParams) { + return this.getPaymentMethods(params); } async getPaymentGateways() { return this.apiMethods.getPaymentGateways(); } - async capturePayment(params: any) { - return this.apiMethods.capturePayment(params); - } - - async addCredit(params: any) { - return this.apiMethods.addCredit(params); - } - - async addInvoicePayment(params: any) { - return this.apiMethods.addInvoicePayment(params); - } - // ========================================== // SSO API METHODS // ========================================== - async createSsoToken(params: any) { + async createSsoToken(params: WhmcsCreateSsoTokenParams) { return this.apiMethods.createSsoToken(params); } // ========================================== - // ADMIN API METHODS + // UTILITY METHODS // ========================================== - async acceptOrder(orderId: number) { - return this.apiMethods.acceptOrder(orderId); + getBaseUrl(): string { + return this.configService.getBaseUrl(); } - async cancelOrder(orderId: number) { - return this.apiMethods.cancelOrder(orderId); - } // ========================================== // UTILITY METHODS diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index 181fb962..29062553 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -7,9 +7,6 @@ import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer"; import { WhmcsCacheService } from "../cache/whmcs-cache.service"; import { WhmcsGetInvoicesParams, - WhmcsCreateInvoiceParams, - WhmcsUpdateInvoiceParams, - WhmcsCapturePaymentParams, WhmcsInvoicesResponse, } from "../types/whmcs-api.types"; @@ -203,224 +200,6 @@ export class WhmcsInvoiceService { this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`); } - // ======================================== - // NEW: Invoice Creation Methods - // ======================================== - - /** - * Create a new invoice for a client - */ - async createInvoice(params: { - clientId: number; - description: string; - amount: number; - currency?: string; - dueDate?: Date; - notes?: string; - }): Promise<{ id: number; number: string; total: number; status: string }> { - try { - const dueDateStr = params.dueDate - ? params.dueDate.toISOString().split("T")[0] - : new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; // 7 days from now - - const whmcsParams: WhmcsCreateInvoiceParams = { - userid: params.clientId, - status: "Unpaid", - sendnotification: false, // Don't send email notification automatically - duedate: dueDateStr, - notes: params.notes, - itemdescription1: params.description, - itemamount1: params.amount, - itemtaxed1: false, // No tax for data top-ups for now - }; - - const response = await this.connectionService.createInvoice(whmcsParams); - - if (response.result !== "success") { - throw new Error(`WHMCS invoice creation failed: ${response.message}`); - } - - this.logger.log(`Created WHMCS invoice ${response.invoiceid} for client ${params.clientId}`, { - invoiceId: response.invoiceid, - amount: params.amount, - description: params.description, - }); - - return { - id: response.invoiceid, - number: `INV-${response.invoiceid}`, - total: params.amount, - status: response.status, - }; - } catch (error) { - this.logger.error(`Failed to create invoice for client ${params.clientId}`, { - error: getErrorMessage(error), - params, - }); - throw error; - } - } - - /** - * Update an existing invoice - */ - async updateInvoice(params: { - invoiceId: number; - status?: - | "Draft" - | "Unpaid" - | "Paid" - | "Cancelled" - | "Refunded" - | "Collections" - | "Payment Pending"; - dueDate?: Date; - notes?: string; - }): Promise<{ success: boolean; message?: string }> { - try { - const whmcsParams: WhmcsUpdateInvoiceParams = { - invoiceid: params.invoiceId, - status: params.status, - duedate: params.dueDate ? params.dueDate.toISOString().split("T")[0] : undefined, - notes: params.notes, - }; - - const response = await this.connectionService.updateInvoice(whmcsParams); - - if (response.result !== "success") { - throw new Error(`WHMCS invoice update failed: ${response.message}`); - } - - this.logger.log(`Updated WHMCS invoice ${params.invoiceId}`, { - invoiceId: params.invoiceId, - status: params.status, - notes: params.notes, - }); - - return { - success: true, - message: response.message, - }; - } catch (error) { - this.logger.error(`Failed to update invoice ${params.invoiceId}`, { - error: getErrorMessage(error), - params, - }); - throw error; - } - } - - /** - * Capture payment for an invoice using the client's default payment method - */ - async capturePayment(params: { - invoiceId: number; - amount: number; - currency?: string; - }): Promise<{ success: boolean; transactionId?: string; error?: string }> { - try { - const whmcsParams: WhmcsCapturePaymentParams = { - invoiceid: params.invoiceId, - }; - - const response = await this.connectionService.capturePayment(whmcsParams); - - if (response.result === "success") { - this.logger.log(`Successfully captured payment for invoice ${params.invoiceId}`, { - invoiceId: params.invoiceId, - transactionId: response.transactionid, - amount: response.amount, - }); - - // Invalidate invoice cache since status changed - await this.cacheService.invalidateInvoice(`invoice-${params.invoiceId}`, params.invoiceId); - - return { - success: true, - transactionId: response.transactionid, - }; - } else { - this.logger.warn(`Payment capture failed for invoice ${params.invoiceId}`, { - invoiceId: params.invoiceId, - error: response.message || response.error, - }); - - // Return user-friendly error message instead of technical API error - const userFriendlyError = this.getUserFriendlyPaymentError( - response.message || response.error || "Unknown payment error" - ); - - return { - success: false, - error: userFriendlyError, - }; - } - } catch (error) { - this.logger.error(`Failed to capture payment for invoice ${params.invoiceId}`, { - error: getErrorMessage(error), - params, - }); - - // Return user-friendly error message for exceptions - const userFriendlyError = this.getUserFriendlyPaymentError(getErrorMessage(error)); - - return { - success: false, - error: userFriendlyError, - }; - } - } - - /** - * Convert technical payment errors to user-friendly messages - */ - private getUserFriendlyPaymentError(technicalError: string): string { - if (!technicalError) { - return "Unable to process payment. Please try again or contact support."; - } - - const errorLower = technicalError.toLowerCase(); - - // WHMCS API permission errors - if (errorLower.includes("invalid permissions") || errorLower.includes("not allowed")) { - return "Payment processing is temporarily unavailable. Please contact support for assistance."; - } - - // Authentication/authorization errors - if ( - errorLower.includes("unauthorized") || - errorLower.includes("forbidden") || - errorLower.includes("403") - ) { - return "Payment processing is temporarily unavailable. Please contact support for assistance."; - } - - // Network/timeout errors - if ( - errorLower.includes("timeout") || - errorLower.includes("network") || - errorLower.includes("connection") - ) { - return "Payment processing timed out. Please try again."; - } - - // Payment method errors - if ( - errorLower.includes("payment method") || - errorLower.includes("card") || - errorLower.includes("insufficient funds") - ) { - return "Unable to process payment with your current payment method. Please check your payment details or try a different method."; - } - - // Generic API errors - if (errorLower.includes("api") || errorLower.includes("http") || errorLower.includes("error")) { - return "Payment processing failed. Please try again or contact support if the issue persists."; - } - - // Default fallback - return "Unable to process payment. Please try again or contact support."; - } private transformInvoicesResponse( response: WhmcsInvoicesResponse, diff --git a/apps/bff/src/integrations/whmcs/transformers/index.ts b/apps/bff/src/integrations/whmcs/transformers/index.ts deleted file mode 100644 index b1afc30b..00000000 --- a/apps/bff/src/integrations/whmcs/transformers/index.ts +++ /dev/null @@ -1,14 +0,0 @@ -// Main orchestrator service -export { WhmcsTransformerOrchestratorService } from "./services/whmcs-transformer-orchestrator.service"; - -// Individual transformer services -export { InvoiceTransformerService } from "./services/invoice-transformer.service"; -export { SubscriptionTransformerService } from "./services/subscription-transformer.service"; -export { PaymentTransformerService } from "./services/payment-transformer.service"; - -// Utilities -export { DataUtils } from "./utils/data-utils"; -export { StatusNormalizer } from "./utils/status-normalizer"; - -// Validators -export { TransformationValidator } from "./validators/transformation-validator"; diff --git a/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts index 30bc52b5..3e0dd596 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts @@ -75,7 +75,7 @@ export class InvoiceTransformerService { const message = DataUtils.toErrorMessage(error); this.logger.error(`Failed to transform invoice ${invoiceId}`, { error: message, - whmcsData: DataUtils.sanitizeForLog(whmcsInvoice as Record), + whmcsData: DataUtils.sanitizeForLog(whmcsInvoice as unknown as Record), }); throw new Error(`Failed to transform invoice: ${message}`); } @@ -97,7 +97,7 @@ export class InvoiceTransformerService { } catch (error) { this.logger.warn("Failed to transform invoice items", { error: DataUtils.toErrorMessage(error), - itemsData: DataUtils.sanitizeForLog(items as Record), + itemsData: DataUtils.sanitizeForLog(items as unknown as Record), }); return []; } @@ -109,9 +109,11 @@ export class InvoiceTransformerService { private transformSingleInvoiceItem(item: Record): InvoiceItem | null { try { const transformedItem: InvoiceItem = { + id: DataUtils.safeNumber(item.id, 0), description: DataUtils.safeString(item.description, "Unknown Item"), amount: DataUtils.parseAmount(item.amount), quantity: DataUtils.safeNumber(item.qty, 1), + type: DataUtils.safeString(item.type, "Unknown"), }; // Add service ID if available @@ -119,10 +121,8 @@ export class InvoiceTransformerService { transformedItem.serviceId = DataUtils.safeNumber(item.relid); } - // Add tax information if available - if (item.taxed === "1" || item.taxed === true) { - transformedItem.taxable = true; - } + // Note: taxable property is not part of the InvoiceItem schema + // Tax information is handled at the invoice level return transformedItem; } catch (error) { diff --git a/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts index 31f941fa..128cf236 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts @@ -26,7 +26,7 @@ export class PaymentTransformerService { whmcsGateway.display_name || whmcsGateway.name, whmcsGateway.name ), - type: DataUtils.safeString(whmcsGateway.type, "unknown"), + type: this.normalizeGatewayType(DataUtils.safeString(whmcsGateway.type, "manual")), isActive: DataUtils.safeBoolean(whmcsGateway.active), acceptsCreditCards: DataUtils.safeBoolean(whmcsGateway.accepts_credit_cards), acceptsBankAccount: DataUtils.safeBoolean(whmcsGateway.accepts_bank_account), @@ -64,12 +64,11 @@ export class PaymentTransformerService { } const transformed: PaymentMethod = { - id: DataUtils.safeString(payMethodId), + id: DataUtils.safeNumber(payMethodId, 0), type: this.normalizePaymentType(gatewayName), - gateway: DataUtils.safeString(gatewayName), description: this.buildPaymentDescription(whmcsPayMethod), + gatewayName: DataUtils.safeString(gatewayName), isDefault: DataUtils.safeBoolean(whmcsPayMethod.is_default || whmcsPayMethod.default), - isActive: DataUtils.safeBoolean(whmcsPayMethod.is_active ?? true), // Default to active if not specified }; // Add credit card specific fields @@ -78,7 +77,7 @@ export class PaymentTransformerService { } if (cardType) { - transformed.cardType = DataUtils.safeString(cardType); + transformed.ccType = DataUtils.safeString(cardType); } if (expiryDate) { @@ -90,9 +89,8 @@ export class PaymentTransformerService { transformed.accountType = DataUtils.safeString(whmcsPayMethod.account_type); } - if (whmcsPayMethod.routing_number) { - transformed.routingNumber = DataUtils.safeString(whmcsPayMethod.routing_number); - } + // Note: routingNumber is not part of the PaymentMethod interface + // This would need to be added to the interface if needed if (!this.validator.validatePaymentMethod(transformed)) { throw new Error("Transformed payment method failed validation"); @@ -257,4 +255,49 @@ export class PaymentTransformerService { return results; } + + // ========================================== + // PRIVATE HELPER METHODS + // ========================================== + + /** + * Normalize gateway type to match our enum + */ + private normalizeGatewayType(type: string): "merchant" | "thirdparty" | "tokenization" | "manual" { + const normalizedType = type.toLowerCase(); + switch (normalizedType) { + case "merchant": + case "credit_card": + case "creditcard": + return "merchant"; + case "thirdparty": + case "third_party": + case "external": + return "thirdparty"; + case "tokenization": + case "token": + return "tokenization"; + default: + return "manual"; + } + } + + /** + * Normalize payment method type to match our enum + */ + private normalizePaymentType(gatewayName?: string): "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount" | "Manual" { + if (!gatewayName) return "Manual"; + + const normalized = gatewayName.toLowerCase(); + if (normalized.includes("credit") || normalized.includes("card") || normalized.includes("visa") || normalized.includes("mastercard")) { + return "CreditCard"; + } + if (normalized.includes("bank") || normalized.includes("ach") || normalized.includes("account")) { + return "BankAccount"; + } + if (normalized.includes("remote") || normalized.includes("token")) { + return "RemoteCreditCard"; + } + return "Manual"; + } } diff --git a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts index 29eaf608..8ba3372c 100644 --- a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts +++ b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts @@ -314,32 +314,6 @@ export interface WhmcsGetPayMethodsParams { [key: string]: unknown; } -export interface WhmcsAddPayMethodParams { - clientid: number; - type: "CreditCard" | "BankAccount" | "RemoteCreditCard"; - description: string; - gateway_name?: string; - // Credit Card specific - card_number?: string; - card_expiry?: string; - cvv?: string; - card_holder_name?: string; - // Bank Account specific - bank_name?: string; - account_type?: string; - routing_number?: string; - account_number?: string; - account_holder_name?: string; - // Remote/Tokenized - remote_token?: string; - // Billing info - billing_contact_id?: number; - [key: string]: unknown; -} - -export interface WhmcsAddPayMethodResponse { - paymethodid: number; -} // Payment Gateway Types export interface WhmcsPaymentGateway { @@ -359,123 +333,7 @@ export interface WhmcsPaymentGatewaysResponse { totalresults: number; } -// ======================================== -// NEW: Invoice Creation and Payment Capture Types -// ======================================== -// CreateInvoice API Types -export interface WhmcsCreateInvoiceParams { - userid: number; - status?: - | "Draft" - | "Unpaid" - | "Paid" - | "Cancelled" - | "Refunded" - | "Collections" - | "Payment Pending"; - sendnotification?: boolean; - paymentmethod?: string; - taxrate?: number; - taxrate2?: number; - date?: string; // YYYY-MM-DD format - duedate?: string; // YYYY-MM-DD format - notes?: string; - itemdescription1?: string; - itemamount1?: number; - itemtaxed1?: boolean; - itemdescription2?: string; - itemamount2?: number; - itemtaxed2?: boolean; - // Can have up to 24 line items (itemdescription1-24, itemamount1-24, itemtaxed1-24) - [key: string]: unknown; -} -export interface WhmcsCreateInvoiceResponse { - result: "success" | "error"; - invoiceid: number; - status: string; - message?: string; -} -// UpdateInvoice API Types -export interface WhmcsUpdateInvoiceParams { - invoiceid: number; - status?: - | "Draft" - | "Unpaid" - | "Paid" - | "Cancelled" - | "Refunded" - | "Collections" - | "Payment Pending"; - duedate?: string; // YYYY-MM-DD format - notes?: string; - [key: string]: unknown; -} -export interface WhmcsUpdateInvoiceResponse { - result: "success" | "error"; - invoiceid: number; - status: string; - message?: string; -} - -// CapturePayment API Types -export interface WhmcsCapturePaymentParams { - invoiceid: number; - cvv?: string; - cardnum?: string; - cccvv?: string; - cardtype?: string; - cardexp?: string; - // For existing payment methods - paymentmethodid?: number; - // Manual payment capture - transid?: string; - gateway?: string; - [key: string]: unknown; -} - -export interface WhmcsCapturePaymentResponse { - result: "success" | "error"; - invoiceid: number; - status: string; - transactionid?: string; - amount?: number; - fees?: number; - message?: string; - error?: string; -} - -// AddCredit API Types (for refunds if needed) -export interface WhmcsAddCreditParams { - clientid: number; - description: string; - amount: number; - type?: "add" | "refund"; - [key: string]: unknown; -} - -export interface WhmcsAddCreditResponse { - result: "success" | "error"; - creditid: number; - message?: string; -} - -// AddInvoicePayment API Types (for manual payment recording) -export interface WhmcsAddInvoicePaymentParams { - invoiceid: number; - transid: string; - amount?: number; - fees?: number; - gateway: string; - date?: string; // YYYY-MM-DD HH:MM:SS format - noemail?: boolean; - [key: string]: unknown; -} - -export interface WhmcsAddInvoicePaymentResponse { - result: "success" | "error"; - message?: string; -} diff --git a/apps/bff/src/integrations/whmcs/whmcs.service.ts b/apps/bff/src/integrations/whmcs/whmcs.service.ts index b106e93d..19345ce7 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.service.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.service.ts @@ -341,53 +341,6 @@ export class WhmcsService { return this.connectionService.getClientsProducts(params); } - // ========================================== - // INVOICE CREATION AND PAYMENT OPERATIONS - // ========================================== - - /** - * Create a new invoice for a client - */ - async createInvoice(params: { - clientId: number; - description: string; - amount: number; - currency?: string; - dueDate?: Date; - notes?: string; - }): Promise<{ id: number; number: string; total: number; status: string }> { - return this.invoiceService.createInvoice(params); - } - - /** - * Update an existing invoice - */ - async updateInvoice(params: { - invoiceId: number; - status?: - | "Draft" - | "Unpaid" - | "Paid" - | "Cancelled" - | "Refunded" - | "Collections" - | "Payment Pending"; - dueDate?: Date; - notes?: string; - }): Promise<{ success: boolean; message?: string }> { - return this.invoiceService.updateInvoice(params); - } - - /** - * Capture payment for an invoice - */ - async capturePayment(params: { - invoiceId: number; - amount: number; - currency?: string; - }): Promise<{ success: boolean; transactionId?: string; error?: string }> { - return this.invoiceService.capturePayment(params); - } // ========================================== // ORDER OPERATIONS (delegate to OrderService) diff --git a/apps/bff/src/modules/invoices/index.ts b/apps/bff/src/modules/invoices/index.ts new file mode 100644 index 00000000..f2b251a5 --- /dev/null +++ b/apps/bff/src/modules/invoices/index.ts @@ -0,0 +1,22 @@ +// Main orchestrator service +export { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service"; + +// Individual services +export { InvoiceRetrievalService } from "./services/invoice-retrieval.service"; +export { InvoiceOperationsService } from "./services/invoice-operations.service"; +export { PaymentMethodsService } from "./services/payment-methods.service"; +export { InvoiceHealthService } from "./services/invoice-health.service"; + +// Validators +export { InvoiceValidatorService } from "./validators/invoice-validator.service"; + +// Types +export type { + GetInvoicesOptions, + InvoiceValidationResult, + InvoiceServiceStats, + InvoiceHealthStatus, + InvoiceStatus, + PaginationOptions, + UserMappingInfo, +} from "./types/invoice-service.types"; diff --git a/apps/bff/src/modules/invoices/invoices.module.ts b/apps/bff/src/modules/invoices/invoices.module.ts index ce7e2c96..43bf6555 100644 --- a/apps/bff/src/modules/invoices/invoices.module.ts +++ b/apps/bff/src/modules/invoices/invoices.module.ts @@ -3,10 +3,28 @@ import { InvoicesController } from "./invoices.controller"; import { InvoicesService } from "./invoices.service"; import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module"; +// New modular invoice services +import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service"; +import { InvoiceRetrievalService } from "./services/invoice-retrieval.service"; +import { InvoiceOperationsService } from "./services/invoice-operations.service"; +import { PaymentMethodsService } from "./services/payment-methods.service"; +import { InvoiceHealthService } from "./services/invoice-health.service"; +import { InvoiceValidatorService } from "./validators/invoice-validator.service"; @Module({ imports: [WhmcsModule, MappingsModule], controllers: [InvoicesController], - providers: [InvoicesService], + providers: [ + // Legacy service (now facade) + InvoicesService, + // New modular services + InvoicesOrchestratorService, + InvoiceRetrievalService, + InvoiceOperationsService, + PaymentMethodsService, + InvoiceHealthService, + InvoiceValidatorService, + ], + exports: [InvoicesService, InvoicesOrchestratorService], }) export class InvoicesModule {} diff --git a/apps/bff/src/modules/invoices/invoices.service.ts b/apps/bff/src/modules/invoices/invoices.service.ts index ee65afb7..4af0f296 100644 --- a/apps/bff/src/modules/invoices/invoices.service.ts +++ b/apps/bff/src/modules/invoices/invoices.service.ts @@ -1,205 +1,67 @@ -import { - Injectable, - NotFoundException, - Inject, - BadRequestException, - InternalServerErrorException, -} from "@nestjs/common"; -import { - Invoice, - InvoiceItem, - InvoiceList, - InvoiceSsoLink, - Subscription, - PaymentMethodList, - PaymentGatewayList, - InvoicePaymentLink, -} from "@customer-portal/domain"; +import { Injectable } from "@nestjs/common"; +import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; -import { getErrorMessage } from "@bff/core/utils/error.util"; -import { Logger } from "nestjs-pino"; +import type { + Invoice, + InvoiceList, + InvoiceSsoLink, + InvoicePaymentLink, + PaymentMethodList, + PaymentGatewayList, +} from "@customer-portal/domain"; +// Re-export the interface for backward compatibility export interface GetInvoicesOptions { page?: number; limit?: number; status?: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections"; } +/** + * Invoices Service - now acts as a facade to the orchestrator service + * Maintains backward compatibility while delegating to modular services + */ @Injectable() export class InvoicesService { constructor( + private readonly orchestrator: InvoicesOrchestratorService, private readonly whmcsService: WhmcsService, - private readonly mappingsService: MappingsService, - @Inject(Logger) private readonly logger: Logger + private readonly mappingsService: MappingsService ) {} /** * Get paginated invoices for a user */ async getInvoices(userId: string, options: GetInvoicesOptions = {}): Promise { - const { page = 1, limit = 10, status } = options; - - try { - // Get WHMCS client ID from user mapping - const mapping = await this.mappingsService.findByUserId(userId); - if (!mapping?.whmcsClientId) { - throw new NotFoundException("WHMCS client mapping not found"); - } - - // Validate pagination parameters - if (page < 1) { - throw new BadRequestException("Page must be greater than 0"); - } - if (limit < 1 || limit > 100) { - throw new BadRequestException("Limit must be between 1 and 100"); - } - - // Fetch invoices from WHMCS - const invoiceList = await this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { - page, - limit, - status, - }); - - this.logger.log(`Retrieved ${invoiceList.invoices.length} invoices for user ${userId}`, { - page, - limit, - status, - totalItems: invoiceList.pagination?.totalItems, - }); - - return invoiceList; - } catch (error) { - this.logger.error(`Failed to get invoices for user ${userId}`, { - error: getErrorMessage(error), - options, - }); - - if (error instanceof NotFoundException) { - throw error; - } - - throw new InternalServerErrorException("Failed to retrieve invoices"); - } + return this.orchestrator.getInvoices(userId, options); } /** * Get individual invoice by ID */ async getInvoiceById(userId: string, invoiceId: number): Promise { - try { - // Validate invoice ID - if (!invoiceId || invoiceId < 1) { - throw new BadRequestException("Invalid invoice ID"); - } - - // Get WHMCS client ID from user mapping - const mapping = await this.mappingsService.findByUserId(userId); - if (!mapping?.whmcsClientId) { - throw new NotFoundException("WHMCS client mapping not found"); - } - - // Fetch invoice from WHMCS - const invoice = await this.whmcsService.getInvoiceById( - mapping.whmcsClientId, - userId, - invoiceId - ); - - this.logger.log(`Retrieved invoice ${invoiceId} for user ${userId}`, { - invoiceNumber: invoice.number, - status: invoice.status, - total: invoice.total, - currency: invoice.currency, - }); - - return invoice; - } catch (error) { - this.logger.error(`Failed to get invoice ${invoiceId} for user ${userId}`, { - error: getErrorMessage(error), - }); - - if (error instanceof NotFoundException) { - throw error; - } - - throw new InternalServerErrorException("Failed to retrieve invoice"); - } + return this.orchestrator.getInvoiceById(userId, invoiceId); } /** - * Create SSO link for invoice viewing, PDF download, or direct payment + * Create SSO link for invoice management */ - async createSsoLink( - userId: string, - invoiceId: number, - target: "view" | "download" | "pay" = "view" - ): Promise { - try { - // Validate invoice ID - if (!invoiceId || invoiceId < 1) { - throw new BadRequestException("Invalid invoice ID"); - } - - // Get WHMCS client ID from user mapping - const mapping = await this.mappingsService.findByUserId(userId); - if (!mapping?.whmcsClientId) { - throw new NotFoundException("WHMCS client mapping not found"); - } - - // Verify the invoice exists and belongs to this user - await this.getInvoiceById(userId, invoiceId); - - // Determine the target path based on the requested action - let path: string; - switch (target) { - case "pay": - // Direct payment page using WHMCS Friendly URLs - path = `index.php?rp=/invoice/${invoiceId}/pay`; - break; - case "download": - // PDF download - path = `dl.php?type=i&id=${invoiceId}`; - break; - case "view": - default: - // Invoice view page - path = `viewinvoice.php?id=${invoiceId}`; - break; - } - - // Create SSO token for the specific invoice - const ssoResult = await this.whmcsService.createSsoToken( - mapping.whmcsClientId, - "sso:custom_redirect", - path - ); - - const result: InvoiceSsoLink = { - url: ssoResult.url, - expiresAt: ssoResult.expiresAt, - }; - - this.logger.log(`Created SSO link for invoice ${invoiceId}, user ${userId}`, { - target, - path, - expiresAt: result.expiresAt, - }); - - return result; - } catch (error) { - this.logger.error(`Failed to create SSO link for invoice ${invoiceId}, user ${userId}`, { - error: getErrorMessage(error), - target, - }); - - if (error instanceof NotFoundException) { - throw error; - } - - throw new InternalServerErrorException("Failed to create SSO link"); + async createInvoiceSsoLink(userId: string, invoiceId?: number): Promise { + const mapping = await this.mappingsService.findByUserId(userId); + if (!mapping?.whmcsClientId) { + throw new Error("WHMCS client mapping not found"); } + + const ssoResult = await this.whmcsService.createSsoToken( + mapping.whmcsClientId, + invoiceId ? `index.php?rp=/invoice/${invoiceId}` : undefined + ); + + return { + url: ssoResult.url, + expiresAt: ssoResult.expiresAt, + }; } /** @@ -210,29 +72,7 @@ export class InvoicesService { status: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections", options: Pick = {} ): Promise { - const { page = 1, limit = 10 } = options; - - try { - // Validate status - const validStatuses = ["Paid", "Unpaid", "Cancelled", "Overdue", "Collections"] as const; - if (!validStatuses.includes(status)) { - throw new BadRequestException( - `Invalid status. Must be one of: ${validStatuses.join(", ")}` - ); - } - - return await this.getInvoices(userId, { page, limit, status }); - } catch (error) { - this.logger.error(`Failed to get ${status} invoices for user ${userId}`, { - error: getErrorMessage(error), - options, - }); - if (error instanceof NotFoundException) { - throw error; - } - - throw new InternalServerErrorException("Failed to retrieve invoices"); - } + return this.orchestrator.getInvoicesByStatus(userId, status, options); } /** @@ -242,7 +82,7 @@ export class InvoicesService { userId: string, options: Pick = {} ): Promise { - return this.getInvoicesByStatus(userId, "Unpaid", options); + return this.orchestrator.getUnpaidInvoices(userId, options); } /** @@ -252,325 +92,124 @@ export class InvoicesService { userId: string, options: Pick = {} ): Promise { - return this.getInvoicesByStatus(userId, "Overdue", options); + return this.orchestrator.getOverdueInvoices(userId, options); } /** - * Get invoice statistics for a user + * Get paid invoices for a user */ - async getInvoiceStats(userId: string): Promise<{ - total: number; - paid: number; - unpaid: number; - overdue: number; - totalAmount: number; - unpaidAmount: number; - currency: string; - }> { - try { - // Get all invoices (first 1000, should be enough for stats) - const invoiceList = await this.getInvoices(userId, { - page: 1, - limit: 1000, - }); - const invoices = invoiceList.invoices; - - if (invoices.length === 0) { - return { - total: 0, - paid: 0, - unpaid: 0, - overdue: 0, - totalAmount: 0, - unpaidAmount: 0, - currency: "JPY", - }; - } - - // Calculate statistics - const stats = { - total: invoices.length, - paid: invoices.filter((i: Invoice) => i.status === "Paid").length, - unpaid: invoices.filter((i: Invoice) => i.status === "Unpaid").length, - overdue: invoices.filter((i: Invoice) => i.status === "Overdue").length, - totalAmount: invoices.reduce((sum: number, i: Invoice) => sum + i.total, 0), - unpaidAmount: invoices - .filter((i: Invoice) => ["Unpaid", "Overdue"].includes(i.status)) - .reduce((sum: number, i: Invoice) => sum + i.total, 0), - currency: invoices[0]?.currency || "JPY", - }; - - this.logger.log(`Generated invoice stats for user ${userId}`, stats); - return stats; - } catch (error) { - this.logger.error(`Failed to generate invoice stats for user ${userId}`, { - error: getErrorMessage(error), - }); - if (error instanceof NotFoundException) { - throw error; - } - throw new InternalServerErrorException("Failed to generate invoice statistics"); - } + async getPaidInvoices( + userId: string, + options: Pick = {} + ): Promise { + return this.orchestrator.getPaidInvoices(userId, options); } /** - * Get subscriptions related to an invoice + * Get cancelled invoices for a user */ - async getInvoiceSubscriptions(userId: string, invoiceId: number): Promise { - try { - // Get the invoice with items - const invoice = await this.getInvoiceById(userId, invoiceId); - - if (!invoice.items || invoice.items.length === 0) { - return []; - } - - // Get WHMCS client ID from user mapping - const mapping = await this.mappingsService.findByUserId(userId); - if (!mapping?.whmcsClientId) { - throw new NotFoundException("WHMCS client mapping not found"); - } - - // Get subscription IDs from invoice items - const subscriptionIds = invoice.items - .filter((item: InvoiceItem) => item.serviceId && item.serviceId > 0) - .map((item: InvoiceItem) => item.serviceId!); - - if (subscriptionIds.length === 0) { - return []; - } - - // Get all subscriptions for the user - const allSubscriptions = await this.whmcsService.getSubscriptions( - mapping.whmcsClientId, - userId - ); - - // Filter subscriptions that are referenced in the invoice - const relatedSubscriptions = allSubscriptions.subscriptions.filter( - (subscription: Subscription) => subscriptionIds.includes(subscription.serviceId) - ); - - this.logger.log( - `Found ${relatedSubscriptions.length} subscriptions for invoice ${invoiceId}`, - { - userId, - invoiceId, - subscriptionIds, - } - ); - - return relatedSubscriptions; - } catch (error) { - this.logger.error(`Failed to get subscriptions for invoice ${invoiceId}`, { - error: getErrorMessage(error), - userId, - invoiceId, - }); - - if (error instanceof NotFoundException) { - throw error; - } - - throw new InternalServerErrorException("Failed to retrieve invoice subscriptions"); - } + async getCancelledInvoices( + userId: string, + options: Pick = {} + ): Promise { + return this.orchestrator.getCancelledInvoices(userId, options); } /** - * Invalidate invoice cache for a user + * Get invoices in collections for a user */ - async invalidateCache(userId: string, invoiceId?: number): Promise { - try { - if (invoiceId) { - await this.whmcsService.invalidateInvoiceCache(userId, invoiceId); - } else { - await this.whmcsService.invalidateUserCache(userId); - } + async getCollectionsInvoices( + userId: string, + options: Pick = {} + ): Promise { + return this.orchestrator.getCollectionsInvoices(userId, options); + } - this.logger.log( - `Invalidated invoice cache for user ${userId}${invoiceId ? `, invoice ${invoiceId}` : ""}` - ); - } catch (error) { - this.logger.error(`Failed to invalidate invoice cache for user ${userId}`, { - error: getErrorMessage(error), - invoiceId, - }); + /** + * Create payment link for a specific invoice + */ + async createInvoicePaymentLink( + userId: string, + invoiceId: number, + gatewayName: string, + returnUrl: string + ): Promise { + const mapping = await this.mappingsService.findByUserId(userId); + if (!mapping?.whmcsClientId) { + throw new Error("WHMCS client mapping not found"); } + + const ssoResult = await this.whmcsService.createPaymentSsoToken( + mapping.whmcsClientId, + invoiceId, + undefined, + gatewayName + ); + + return { + url: ssoResult.url, + expiresAt: ssoResult.expiresAt, + gatewayName, + }; } /** * Get payment methods for a user */ async getPaymentMethods(userId: string): Promise { - try { - this.logger.log(`Starting payment methods retrieval for user ${userId}`); - - // Get WHMCS client ID from user mapping - const mapping = await this.mappingsService.findByUserId(userId); - if (!mapping?.whmcsClientId) { - this.logger.error(`No WHMCS client mapping found for user ${userId}`); - throw new NotFoundException("WHMCS client mapping not found"); - } - - this.logger.log(`Found WHMCS client ID ${mapping.whmcsClientId} for user ${userId}`); - - // Fetch payment methods from WHMCS - const paymentMethods = await this.whmcsService.getPaymentMethods( - mapping.whmcsClientId, - userId - ); - - this.logger.log( - `Retrieved ${paymentMethods.paymentMethods.length} payment methods for user ${userId} (client ${mapping.whmcsClientId})` - ); - return paymentMethods; - } catch (error) { - this.logger.error(`Failed to get payment methods for user ${userId}`, { - error: getErrorMessage(error), - errorType: error instanceof Error ? error.constructor.name : typeof error, - errorMessage: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - }); - - if (error instanceof NotFoundException) { - throw error; - } - - throw new InternalServerErrorException("Failed to retrieve payment methods"); - } - } - - /** - * Invalidate payment methods cache for a user - */ - async invalidatePaymentMethodsCache(userId: string): Promise { - try { - // Get WHMCS client ID from user mapping - const mapping = await this.mappingsService.findByUserId(userId); - if (!mapping?.whmcsClientId) { - throw new NotFoundException("WHMCS client mapping not found"); - } - - // Invalidate WHMCS payment methods cache - await this.whmcsService.invalidatePaymentMethodsCache(userId); - - this.logger.log(`Invalidated payment methods cache for user ${userId}`); - } catch (error) { - this.logger.error(`Failed to invalidate payment methods cache for user ${userId}`, { - error: getErrorMessage(error), - }); - throw new InternalServerErrorException("Failed to invalidate payment methods cache"); + const mapping = await this.mappingsService.findByUserId(userId); + if (!mapping?.whmcsClientId) { + throw new Error("WHMCS client mapping not found"); } + return this.whmcsService.getPaymentMethods(mapping.whmcsClientId, userId); } /** * Get available payment gateways */ async getPaymentGateways(): Promise { - try { - // Fetch payment gateways from WHMCS - const paymentGateways = await this.whmcsService.getPaymentGateways(); - - this.logger.log(`Retrieved ${paymentGateways.gateways.length} payment gateways`); - return paymentGateways; - } catch (error) { - this.logger.error("Failed to get payment gateways", { - error: getErrorMessage(error), - }); - - throw new InternalServerErrorException("Failed to retrieve payment gateways"); - } + return this.whmcsService.getPaymentGateways(); } /** - * Create payment SSO link for invoice with specific payment method or gateway + * Invalidate payment methods cache for a user */ - async createPaymentSsoLink( - userId: string, - invoiceId: number, - paymentMethodId?: number, - gatewayName?: string - ): Promise { - try { - // Validate invoice ID - if (!invoiceId || invoiceId < 1) { - throw new BadRequestException("Invalid invoice ID"); - } - - // Get WHMCS client ID from user mapping - const mapping = await this.mappingsService.findByUserId(userId); - if (!mapping?.whmcsClientId) { - throw new NotFoundException("WHMCS client mapping not found"); - } - - // Verify the invoice exists and belongs to this user - await this.getInvoiceById(userId, invoiceId); - - // Create payment SSO token with specific payment method or gateway - const ssoResult = await this.whmcsService.createPaymentSsoToken( - mapping.whmcsClientId, - invoiceId, - paymentMethodId, - gatewayName - ); - - const result: InvoicePaymentLink = { - url: ssoResult.url, - expiresAt: ssoResult.expiresAt, - paymentMethodId, - gatewayName, - }; - - this.logger.log(`Created payment SSO link for invoice ${invoiceId}, user ${userId}`, { - paymentMethodId, - gatewayName, - expiresAt: result.expiresAt, - }); - - return result; - } catch (error) { - this.logger.error( - `Failed to create payment SSO link for invoice ${invoiceId}, user ${userId}`, - { - error: getErrorMessage(error), - paymentMethodId, - gatewayName, - } - ); - - if (error instanceof NotFoundException) { - throw error; - } - - throw new InternalServerErrorException("Failed to create payment SSO link"); - } + async invalidatePaymentMethodsCache(userId: string): Promise { + return this.whmcsService.invalidatePaymentMethodsCache(userId); } /** * Health check for invoice service */ async healthCheck(): Promise<{ status: string; details: unknown }> { - try { - const whmcsHealthy = await this.whmcsService.healthCheck(); - - return { - status: whmcsHealthy ? "healthy" : "unhealthy", - details: { - whmcsApi: whmcsHealthy ? "connected" : "disconnected", - 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(), - }, - }; - } + const health = await this.orchestrator.healthCheck(); + return { + status: health.status, + details: health.details, + }; } -} + + /** + * Get service statistics + */ + getServiceStats() { + return this.orchestrator.getServiceStats(); + } + + /** + * Check if user has any invoices + */ + async hasInvoices(userId: string): Promise { + return this.orchestrator.hasInvoices(userId); + } + + /** + * Get invoice count by status + */ + async getInvoiceCountByStatus( + userId: string, + status: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections" + ): Promise { + return this.orchestrator.getInvoiceCountByStatus(userId, status); + } +} \ No newline at end of file diff --git a/apps/bff/src/modules/invoices/services/invoice-health.service.ts b/apps/bff/src/modules/invoices/services/invoice-health.service.ts new file mode 100644 index 00000000..4843d619 --- /dev/null +++ b/apps/bff/src/modules/invoices/services/invoice-health.service.ts @@ -0,0 +1,193 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; +import { getErrorMessage } from "@bff/core/utils/error.util"; +import type { InvoiceHealthStatus, InvoiceServiceStats } from "../types/invoice-service.types"; + +/** + * 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/invoices/services/invoice-retrieval.service.ts b/apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts new file mode 100644 index 00000000..cae6dc67 --- /dev/null +++ b/apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts @@ -0,0 +1,221 @@ +import { Injectable, NotFoundException, InternalServerErrorException, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { Invoice, InvoiceList } from "@customer-portal/domain"; +import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; +import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; +import { getErrorMessage } from "@bff/core/utils/error.util"; +import { InvoiceValidatorService } from "../validators/invoice-validator.service"; +import type { + GetInvoicesOptions, + InvoiceStatus, + PaginationOptions, + UserMappingInfo +} from "../types/invoice-service.types"; + +/** + * Service responsible for retrieving invoices from WHMCS + */ +@Injectable() +export class InvoiceRetrievalService { + constructor( + private readonly whmcsService: WhmcsService, + private readonly mappingsService: MappingsService, + private readonly validator: InvoiceValidatorService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Get paginated invoices for a user + */ + async getInvoices(userId: string, options: GetInvoicesOptions = {}): Promise { + const { page = 1, limit = 10, status } = options; + + try { + // Validate inputs + this.validator.validateUserId(userId); + this.validator.validatePagination({ page, limit }); + + if (status) { + this.validator.validateInvoiceStatus(status); + } + + // Get user mapping + const mapping = await this.getUserMapping(userId); + + // Fetch invoices from WHMCS + const invoiceList = await this.whmcsService.getInvoices(mapping.whmcsClientId, userId, { + page, + limit, + status, + }); + + this.logger.log(`Retrieved ${invoiceList.invoices.length} invoices for user ${userId}`, { + page, + limit, + status, + totalItems: invoiceList.pagination?.totalItems, + }); + + return invoiceList; + } catch (error) { + this.logger.error(`Failed to get invoices for user ${userId}`, { + error: getErrorMessage(error), + options, + }); + + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException("Failed to retrieve invoices"); + } + } + + /** + * Get individual invoice by ID + */ + async getInvoiceById(userId: string, invoiceId: number): Promise { + try { + // Validate inputs + this.validator.validateUserId(userId); + this.validator.validateInvoiceId(invoiceId); + + // Get user mapping + const mapping = await this.getUserMapping(userId); + + // Fetch invoice from WHMCS + const invoice = await this.whmcsService.getInvoiceById( + mapping.whmcsClientId, + userId, + invoiceId + ); + + this.logger.log(`Retrieved invoice ${invoiceId} for user ${userId}`); + return invoice; + } catch (error) { + this.logger.error(`Failed to get invoice ${invoiceId} for user ${userId}`, { + error: getErrorMessage(error), + }); + + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException("Failed to retrieve invoice"); + } + } + + /** + * Get invoices by status + */ + async getInvoicesByStatus( + userId: string, + status: InvoiceStatus, + options: PaginationOptions = {} + ): Promise { + const { page = 1, limit = 10 } = options; + + try { + // Validate inputs + this.validator.validateUserId(userId); + this.validator.validateInvoiceStatus(status); + this.validator.validatePagination({ page, limit }); + + return await this.getInvoices(userId, { page, limit, status }); + } catch (error) { + this.logger.error(`Failed to get ${status} invoices for user ${userId}`, { + error: getErrorMessage(error), + options, + }); + + if (error instanceof NotFoundException) { + throw error; + } + + throw new InternalServerErrorException("Failed to retrieve invoices"); + } + } + + /** + * Get unpaid invoices for a user + */ + async getUnpaidInvoices(userId: string, options: PaginationOptions = {}): Promise { + return this.getInvoicesByStatus(userId, "Unpaid", options); + } + + /** + * Get overdue invoices for a user + */ + async getOverdueInvoices(userId: string, options: PaginationOptions = {}): Promise { + return this.getInvoicesByStatus(userId, "Overdue", options); + } + + /** + * Get paid invoices for a user + */ + async getPaidInvoices(userId: string, options: PaginationOptions = {}): Promise { + return this.getInvoicesByStatus(userId, "Paid", options); + } + + /** + * Get cancelled invoices for a user + */ + async getCancelledInvoices(userId: string, options: PaginationOptions = {}): Promise { + return this.getInvoicesByStatus(userId, "Cancelled", options); + } + + /** + * Get invoices in collections for a user + */ + async getCollectionsInvoices(userId: string, options: PaginationOptions = {}): Promise { + return this.getInvoicesByStatus(userId, "Collections", options); + } + + /** + * Get user mapping with validation + */ + private async getUserMapping(userId: string): Promise { + const mapping = await this.mappingsService.findByUserId(userId); + + if (!mapping?.whmcsClientId) { + throw new NotFoundException("WHMCS client mapping not found"); + } + + this.validator.validateWhmcsClientId(mapping.whmcsClientId); + + return { + userId, + whmcsClientId: mapping.whmcsClientId, + }; + } + + /** + * Check if user has any invoices + */ + async hasInvoices(userId: string): Promise { + try { + const invoices = await this.getInvoices(userId, { page: 1, limit: 1 }); + return invoices.invoices.length > 0; + } catch (error) { + this.logger.warn(`Failed to check if user ${userId} has invoices`, { + error: getErrorMessage(error), + }); + return false; + } + } + + /** + * Get invoice count by status + */ + async getInvoiceCountByStatus(userId: string, status: InvoiceStatus): Promise { + try { + const invoices = await this.getInvoicesByStatus(userId, status, { page: 1, limit: 1 }); + return invoices.pagination?.totalItems || 0; + } catch (error) { + this.logger.warn(`Failed to get ${status} invoice count for user ${userId}`, { + error: getErrorMessage(error), + }); + return 0; + } + } +} diff --git a/apps/bff/src/modules/invoices/services/invoices-orchestrator.service.ts b/apps/bff/src/modules/invoices/services/invoices-orchestrator.service.ts new file mode 100644 index 00000000..a616eeb3 --- /dev/null +++ b/apps/bff/src/modules/invoices/services/invoices-orchestrator.service.ts @@ -0,0 +1,206 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { + Invoice, + InvoiceList, + InvoiceSsoLink, + InvoicePaymentLink, + PaymentMethodList, + PaymentGatewayList +} from "@customer-portal/domain"; +import { InvoiceRetrievalService } from "./invoice-retrieval.service"; +import { InvoiceHealthService } from "./invoice-health.service"; +import { InvoiceValidatorService } from "../validators/invoice-validator.service"; +import type { + GetInvoicesOptions, + InvoiceStatus, + PaginationOptions, + InvoiceHealthStatus, + InvoiceServiceStats +} from "../types/invoice-service.types"; + +/** + * 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, + private readonly validator: InvoiceValidatorService, + @Inject(Logger) private readonly logger: Logger + ) {} + + // ========================================== + // INVOICE RETRIEVAL METHODS + // ========================================== + + /** + * Get paginated invoices for a user + */ + async getInvoices(userId: string, options: GetInvoicesOptions = {}): 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: PaginationOptions = {} + ): 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: PaginationOptions = {}): Promise { + return this.retrievalService.getUnpaidInvoices(userId, options); + } + + /** + * Get overdue invoices for a user + */ + async getOverdueInvoices(userId: string, options: PaginationOptions = {}): Promise { + return this.retrievalService.getOverdueInvoices(userId, options); + } + + /** + * Get paid invoices for a user + */ + async getPaidInvoices(userId: string, options: PaginationOptions = {}): Promise { + return this.retrievalService.getPaidInvoices(userId, options); + } + + /** + * Get cancelled invoices for a user + */ + async getCancelledInvoices(userId: string, options: PaginationOptions = {}): Promise { + return this.retrievalService.getCancelledInvoices(userId, options); + } + + /** + * Get invoices in collections for a user + */ + async getCollectionsInvoices(userId: string, options: PaginationOptions = {}): 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(); + } + + /** + * Validate get invoices options + */ + validateGetInvoicesOptions(options: GetInvoicesOptions) { + return this.validator.validateGetInvoicesOptions(options); + } + + /** + * Get valid invoice statuses + */ + getValidStatuses() { + return this.validator.getValidStatuses(); + } + + /** + * Get pagination limits + */ + getPaginationLimits() { + return this.validator.getPaginationLimits(); + } +} diff --git a/apps/bff/src/modules/invoices/types/invoice-service.types.ts b/apps/bff/src/modules/invoices/types/invoice-service.types.ts new file mode 100644 index 00000000..b109f988 --- /dev/null +++ b/apps/bff/src/modules/invoices/types/invoice-service.types.ts @@ -0,0 +1,41 @@ +export interface GetInvoicesOptions { + page?: number; + limit?: number; + status?: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections"; +} + +export interface InvoiceValidationResult { + isValid: boolean; + errors: string[]; +} + +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; + }; +} + +export type InvoiceStatus = "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections"; + +export interface PaginationOptions { + page?: number; + limit?: number; +} + +export interface UserMappingInfo { + userId: string; + whmcsClientId: number; +} diff --git a/apps/bff/src/modules/invoices/validators/invoice-validator.service.ts b/apps/bff/src/modules/invoices/validators/invoice-validator.service.ts new file mode 100644 index 00000000..a520d646 --- /dev/null +++ b/apps/bff/src/modules/invoices/validators/invoice-validator.service.ts @@ -0,0 +1,164 @@ +import { Injectable, BadRequestException } from "@nestjs/common"; +import type { + GetInvoicesOptions, + InvoiceValidationResult, + InvoiceStatus, + PaginationOptions +} from "../types/invoice-service.types"; + +/** + * Service for validating invoice-related inputs and business rules + */ +@Injectable() +export class InvoiceValidatorService { + private readonly validStatuses: readonly InvoiceStatus[] = [ + "Paid", "Unpaid", "Cancelled", "Overdue", "Collections" + ] as const; + + private readonly maxLimit = 100; + private readonly minLimit = 1; + + /** + * Validate invoice ID + */ + validateInvoiceId(invoiceId: number): void { + if (!invoiceId || invoiceId < 1) { + throw new BadRequestException("Invalid invoice ID"); + } + } + + /** + * Validate user ID + */ + validateUserId(userId: string): void { + if (!userId || typeof userId !== "string" || userId.trim().length === 0) { + throw new BadRequestException("Invalid user ID"); + } + } + + /** + * Validate pagination parameters + */ + validatePagination(options: PaginationOptions): void { + const { page = 1, limit = 10 } = options; + + if (page < 1) { + throw new BadRequestException("Page must be greater than 0"); + } + + if (limit < this.minLimit || limit > this.maxLimit) { + throw new BadRequestException(`Limit must be between ${this.minLimit} and ${this.maxLimit}`); + } + } + + /** + * Validate invoice status + */ + validateInvoiceStatus(status: string): InvoiceStatus { + if (!this.validStatuses.includes(status as InvoiceStatus)) { + throw new BadRequestException( + `Invalid status. Must be one of: ${this.validStatuses.join(", ")}` + ); + } + return status as InvoiceStatus; + } + + /** + * Validate get invoices options + */ + validateGetInvoicesOptions(options: GetInvoicesOptions): InvoiceValidationResult { + const errors: string[] = []; + + try { + this.validatePagination(options); + } catch (error) { + if (error instanceof BadRequestException) { + errors.push(error.message); + } + } + + if (options.status) { + try { + this.validateInvoiceStatus(options.status); + } catch (error) { + if (error instanceof BadRequestException) { + errors.push(error.message); + } + } + } + + return { + isValid: errors.length === 0, + errors, + }; + } + + /** + * Validate WHMCS client ID + */ + validateWhmcsClientId(clientId: number | undefined): void { + if (!clientId || clientId < 1) { + throw new BadRequestException("Invalid WHMCS client ID"); + } + } + + /** + * Validate payment gateway name + */ + validatePaymentGateway(gatewayName: string): void { + if (!gatewayName || typeof gatewayName !== "string" || gatewayName.trim().length === 0) { + throw new BadRequestException("Invalid payment gateway name"); + } + } + + /** + * Validate return URL for payment links + */ + validateReturnUrl(returnUrl: string): void { + if (!returnUrl || typeof returnUrl !== "string") { + throw new BadRequestException("Return URL is required"); + } + + try { + new URL(returnUrl); + } catch { + throw new BadRequestException("Invalid return URL format"); + } + } + + /** + * Get valid invoice statuses + */ + getValidStatuses(): readonly InvoiceStatus[] { + return this.validStatuses; + } + + /** + * Get pagination limits + */ + getPaginationLimits(): { min: number; max: number } { + return { + min: this.minLimit, + max: this.maxLimit, + }; + } + + /** + * Sanitize pagination options with defaults + */ + sanitizePaginationOptions(options: PaginationOptions): Required { + const { page = 1, limit = 10 } = options; + + return { + page: Math.max(1, Math.floor(page)), + limit: Math.max(this.minLimit, Math.min(this.maxLimit, Math.floor(limit))), + }; + } + + /** + * Check if status is a valid invoice status + */ + isValidStatus(status: string): status is InvoiceStatus { + return this.validStatuses.includes(status as InvoiceStatus); + } +} diff --git a/apps/portal/src/app/(public)/auth/loading.tsx b/apps/portal/src/app/(public)/auth/loading.tsx index bcf7152f..4fffdb60 100644 --- a/apps/portal/src/app/(public)/auth/loading.tsx +++ b/apps/portal/src/app/(public)/auth/loading.tsx @@ -1,4 +1,4 @@ -import { AuthLayout } from "@/components/templates/AuthLayout"; +import { AuthLayout } from "@/components/templates/AuthLayout/AuthLayout"; import { Skeleton } from "@/components/atoms/loading-skeleton"; export default function AuthSegmentLoading() { diff --git a/apps/portal/src/components/molecules/AlertBanner/index.ts b/apps/portal/src/components/molecules/AlertBanner/index.ts deleted file mode 100644 index 4a7b45cb..00000000 --- a/apps/portal/src/components/molecules/AlertBanner/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./AlertBanner"; diff --git a/apps/portal/src/components/molecules/AsyncBlock/index.ts b/apps/portal/src/components/molecules/AsyncBlock/index.ts deleted file mode 100644 index fbdba92e..00000000 --- a/apps/portal/src/components/molecules/AsyncBlock/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./AsyncBlock"; diff --git a/apps/portal/src/components/molecules/DetailHeader/index.ts b/apps/portal/src/components/molecules/DetailHeader/index.ts deleted file mode 100644 index 0226dfb7..00000000 --- a/apps/portal/src/components/molecules/DetailHeader/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./DetailHeader"; diff --git a/apps/portal/src/components/molecules/PaginationBar/index.ts b/apps/portal/src/components/molecules/PaginationBar/index.ts deleted file mode 100644 index 65ca19cb..00000000 --- a/apps/portal/src/components/molecules/PaginationBar/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./PaginationBar"; diff --git a/apps/portal/src/components/molecules/ProgressSteps/ProgressSteps.tsx b/apps/portal/src/components/molecules/ProgressSteps/ProgressSteps.tsx index 4311ce18..8c08d7d1 100644 --- a/apps/portal/src/components/molecules/ProgressSteps/ProgressSteps.tsx +++ b/apps/portal/src/components/molecules/ProgressSteps/ProgressSteps.tsx @@ -1,5 +1,5 @@ import { CheckCircleIcon } from "@heroicons/react/24/outline"; -import { AnimatedCard } from "@/components/molecules/AnimatedCard"; +import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard"; interface Step { number: number; diff --git a/apps/portal/src/components/molecules/index.ts b/apps/portal/src/components/molecules/index.ts index 9f28c0c4..d6166f33 100644 --- a/apps/portal/src/components/molecules/index.ts +++ b/apps/portal/src/components/molecules/index.ts @@ -13,10 +13,10 @@ export type { FormFieldProps } from "./FormField/FormField"; export { SearchFilterBar } from "./SearchFilterBar/SearchFilterBar"; export type { SearchFilterBarProps, FilterOption } from "./SearchFilterBar/SearchFilterBar"; -export * from "./PaginationBar"; -export * from "./DetailHeader"; -export * from "./AlertBanner"; -export * from "./AsyncBlock"; +export * from "./PaginationBar/PaginationBar"; +export * from "./DetailHeader/DetailHeader"; +export * from "./AlertBanner/AlertBanner"; +export * from "./AsyncBlock/AsyncBlock"; export * from "./SectionHeader/SectionHeader"; export * from "./ProgressSteps/ProgressSteps"; export * from "./SubCard/SubCard"; diff --git a/apps/portal/src/features/account/components/PersonalInfoCard.tsx b/apps/portal/src/features/account/components/PersonalInfoCard.tsx index 5691b660..2561e5d2 100644 --- a/apps/portal/src/features/account/components/PersonalInfoCard.tsx +++ b/apps/portal/src/features/account/components/PersonalInfoCard.tsx @@ -2,15 +2,15 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard"; import { UserIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline"; -import type { ProfileEditFormData } from "@customer-portal/domain"; +import type { ProfileDisplayData } from "@customer-portal/domain"; interface PersonalInfoCardProps { - data: ProfileEditFormData; + data: ProfileDisplayData; isEditing: boolean; isSaving: boolean; onEdit: () => void; onCancel: () => void; - onChange: (field: keyof ProfileEditFormData, value: string) => void; + onChange: (field: keyof ProfileDisplayData, value: string) => void; onSave: () => void; } diff --git a/apps/portal/src/features/account/views/ProfileContainer.tsx b/apps/portal/src/features/account/views/ProfileContainer.tsx index 455500f2..6d8ca5da 100644 --- a/apps/portal/src/features/account/views/ProfileContainer.tsx +++ b/apps/portal/src/features/account/views/ProfileContainer.tsx @@ -2,7 +2,7 @@ import { useEffect, useState } from "react"; import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton"; -import { AlertBanner } from "@/components/molecules/AlertBanner"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon, UserIcon } from "@heroicons/react/24/outline"; import { useAuthStore } from "@/features/auth/services/auth.store"; import { accountService } from "@/features/account/services/account.service"; diff --git a/apps/portal/src/features/auth/components/LinkWhmcsForm/index.ts b/apps/portal/src/features/auth/components/LinkWhmcsForm/index.ts deleted file mode 100644 index 65d52ecc..00000000 --- a/apps/portal/src/features/auth/components/LinkWhmcsForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { LinkWhmcsForm } from "./LinkWhmcsForm"; diff --git a/apps/portal/src/features/auth/components/LoginForm/index.ts b/apps/portal/src/features/auth/components/LoginForm/index.ts deleted file mode 100644 index 7262daaa..00000000 --- a/apps/portal/src/features/auth/components/LoginForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { LoginForm } from "./LoginForm"; diff --git a/apps/portal/src/features/auth/components/PasswordResetForm/index.ts b/apps/portal/src/features/auth/components/PasswordResetForm/index.ts deleted file mode 100644 index ea0848bd..00000000 --- a/apps/portal/src/features/auth/components/PasswordResetForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { PasswordResetForm } from "./PasswordResetForm"; diff --git a/apps/portal/src/features/auth/components/SetPasswordForm/index.ts b/apps/portal/src/features/auth/components/SetPasswordForm/index.ts deleted file mode 100644 index e9e92ae1..00000000 --- a/apps/portal/src/features/auth/components/SetPasswordForm/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { SetPasswordForm } from "./SetPasswordForm"; diff --git a/apps/portal/src/features/auth/components/index.ts b/apps/portal/src/features/auth/components/index.ts index 96f4b9ac..5742e6ad 100644 --- a/apps/portal/src/features/auth/components/index.ts +++ b/apps/portal/src/features/auth/components/index.ts @@ -3,9 +3,9 @@ * Centralized exports for authentication components */ -export { LoginForm } from "./LoginForm"; -export { SignupForm } from "./SignupForm"; -export { PasswordResetForm } from "./PasswordResetForm"; -export { SetPasswordForm } from "./SetPasswordForm"; -export { LinkWhmcsForm } from "./LinkWhmcsForm"; +export { LoginForm } from "./LoginForm/LoginForm"; +export { SignupForm } from "./SignupForm/SignupForm"; +export { PasswordResetForm } from "./PasswordResetForm/PasswordResetForm"; +export { SetPasswordForm } from "./SetPasswordForm/SetPasswordForm"; +export { LinkWhmcsForm } from "./LinkWhmcsForm/LinkWhmcsForm"; export { AuthLayout } from "@/components/templates/AuthLayout"; diff --git a/apps/portal/src/features/billing/components/BillingStatusBadge/index.ts b/apps/portal/src/features/billing/components/BillingStatusBadge/index.ts deleted file mode 100644 index 921684f1..00000000 --- a/apps/portal/src/features/billing/components/BillingStatusBadge/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./BillingStatusBadge"; diff --git a/apps/portal/src/features/billing/components/BillingSummary/index.ts b/apps/portal/src/features/billing/components/BillingSummary/index.ts deleted file mode 100644 index 5db6ee3e..00000000 --- a/apps/portal/src/features/billing/components/BillingSummary/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./BillingSummary"; diff --git a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceHeader.tsx b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceHeader.tsx index 5af0e839..98070144 100644 --- a/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceHeader.tsx +++ b/apps/portal/src/features/billing/components/InvoiceDetail/InvoiceHeader.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { DetailHeader } from "@/components/molecules/DetailHeader"; +import { DetailHeader } from "@/components/molecules/DetailHeader/DetailHeader"; import { Skeleton } from "@/components/atoms/loading-skeleton"; import { ArrowTopRightOnSquareIcon, diff --git a/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx b/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx index c43e343d..98a14856 100644 --- a/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx +++ b/apps/portal/src/features/billing/components/InvoiceList/InvoiceList.tsx @@ -3,9 +3,9 @@ import React, { useMemo, useState } from "react"; import { SubCard } from "@/components/molecules/SubCard/SubCard"; import { LoadingTable } from "@/components/atoms/loading-skeleton"; -import { AsyncBlock } from "@/components/molecules/AsyncBlock"; +import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar"; -import { PaginationBar } from "@/components/molecules/PaginationBar"; +import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar"; import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable"; import { useInvoices } from "@/features/billing/hooks/useBilling"; import { useSubscriptionInvoices } from "@/features/subscriptions/hooks/useSubscriptions"; diff --git a/apps/portal/src/features/billing/components/InvoiceList/index.ts b/apps/portal/src/features/billing/components/InvoiceList/index.ts deleted file mode 100644 index 62e7de19..00000000 --- a/apps/portal/src/features/billing/components/InvoiceList/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./InvoiceList"; diff --git a/apps/portal/src/features/billing/components/InvoiceTable/index.ts b/apps/portal/src/features/billing/components/InvoiceTable/index.ts deleted file mode 100644 index 65aec23d..00000000 --- a/apps/portal/src/features/billing/components/InvoiceTable/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./InvoiceTable"; diff --git a/apps/portal/src/features/billing/components/PaymentMethodCard/index.ts b/apps/portal/src/features/billing/components/PaymentMethodCard/index.ts deleted file mode 100644 index f5e765a4..00000000 --- a/apps/portal/src/features/billing/components/PaymentMethodCard/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./PaymentMethodCard"; diff --git a/apps/portal/src/features/billing/views/InvoiceDetail.tsx b/apps/portal/src/features/billing/views/InvoiceDetail.tsx index b9cb4d00..058efe63 100644 --- a/apps/portal/src/features/billing/views/InvoiceDetail.tsx +++ b/apps/portal/src/features/billing/views/InvoiceDetail.tsx @@ -18,7 +18,7 @@ import { InvoiceItems, InvoiceTotals, } from "@/features/billing/components/InvoiceDetail"; -import { AlertBanner } from "@/components/molecules/AlertBanner"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { InvoicePaymentActions } from "@/features/billing/components/InvoiceDetail/InvoicePaymentActions"; export function InvoiceDetailContainer() { diff --git a/apps/portal/src/features/billing/views/PaymentMethods.tsx b/apps/portal/src/features/billing/views/PaymentMethods.tsx index 30e6b4b1..ac6b4232 100644 --- a/apps/portal/src/features/billing/views/PaymentMethods.tsx +++ b/apps/portal/src/features/billing/views/PaymentMethods.tsx @@ -15,7 +15,7 @@ import { CreditCardIcon, PlusIcon } from "@heroicons/react/24/outline"; import { InlineToast } from "@/components/atoms/inline-toast"; import { SectionHeader } from "@/components/molecules"; import { Button } from "@/components/atoms/button"; -import { AsyncBlock } from "@/components/molecules/AsyncBlock"; +import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton"; import { logger } from "@customer-portal/logging"; import { EmptyState } from "@/components/atoms/empty-state"; diff --git a/apps/portal/src/features/catalog/components/base/AddonGroup.tsx b/apps/portal/src/features/catalog/components/base/AddonGroup.tsx index ee93e522..ea0f366f 100644 --- a/apps/portal/src/features/catalog/components/base/AddonGroup.tsx +++ b/apps/portal/src/features/catalog/components/base/AddonGroup.tsx @@ -5,7 +5,7 @@ import type { CatalogProductBase } from "@customer-portal/domain"; import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing"; interface AddonGroupProps { - addons: Array; + addons: Array; selectedAddonSkus: string[]; onAddonToggle: (skus: string[]) => void; showSkus?: boolean; @@ -23,7 +23,7 @@ type BundledAddonGroup = { }; function buildGroupedAddons( - addons: Array + addons: Array ): BundledAddonGroup[] { const groups: BundledAddonGroup[] = []; const processedSkus = new Set(); @@ -34,7 +34,12 @@ function buildGroupedAddons( if (processedSkus.has(addon.sku)) return; if (addon.isBundledAddon && addon.bundledAddonId) { - const partner = sorted.find(candidate => candidate.raw.Id === addon.bundledAddonId); + const partner = sorted.find(candidate => + candidate.raw && + typeof candidate.raw === 'object' && + 'Id' in candidate.raw && + candidate.raw.Id === addon.bundledAddonId + ); if (partner) { const monthlyAddon = addon.billingCycle === "Monthly" ? addon : partner; diff --git a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx b/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx index a8fdfbdc..d8397848 100644 --- a/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx +++ b/apps/portal/src/features/catalog/components/base/AddressConfirmation.tsx @@ -1,7 +1,7 @@ "use client"; import { Skeleton } from "@/components/atoms"; -import { AlertBanner } from "@/components/molecules/AlertBanner"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { Button } from "@/components/atoms/button"; import { SubCard } from "@/components/molecules/SubCard/SubCard"; diff --git a/apps/portal/src/features/catalog/components/base/ConfigurationStep.tsx b/apps/portal/src/features/catalog/components/base/ConfigurationStep.tsx index 8c1c5907..7548c266 100644 --- a/apps/portal/src/features/catalog/components/base/ConfigurationStep.tsx +++ b/apps/portal/src/features/catalog/components/base/ConfigurationStep.tsx @@ -7,7 +7,7 @@ import { ExclamationTriangleIcon, InformationCircleIcon, } from "@heroicons/react/24/outline"; -import { AnimatedCard } from "@/components/molecules/AnimatedCard"; +import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard"; import { Button } from "@/components/atoms/button"; export interface StepValidation { diff --git a/apps/portal/src/features/catalog/components/base/PaymentForm.tsx b/apps/portal/src/features/catalog/components/base/PaymentForm.tsx index 0d15fb64..1e71db90 100644 --- a/apps/portal/src/features/catalog/components/base/PaymentForm.tsx +++ b/apps/portal/src/features/catalog/components/base/PaymentForm.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from "react"; import { Skeleton } from "@/components/atoms/loading-skeleton"; import { Button } from "@/components/atoms/button"; -import { AlertBanner } from "@/components/molecules/AlertBanner"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { CreditCardIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; import type { PaymentMethod } from "@customer-portal/domain"; diff --git a/apps/portal/src/features/catalog/components/base/ProductComparison.tsx b/apps/portal/src/features/catalog/components/base/ProductComparison.tsx index f74ff076..3c030a35 100644 --- a/apps/portal/src/features/catalog/components/base/ProductComparison.tsx +++ b/apps/portal/src/features/catalog/components/base/ProductComparison.tsx @@ -2,7 +2,7 @@ import { ReactNode } from "react"; import { CheckIcon, XMarkIcon, CurrencyYenIcon } from "@heroicons/react/24/outline"; -import { AnimatedCard } from "@/components/molecules/AnimatedCard"; +import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard"; import { Button } from "@/components/atoms/button"; export interface ComparisonProduct { diff --git a/apps/portal/src/features/catalog/components/common/FeatureCard.tsx b/apps/portal/src/features/catalog/components/common/FeatureCard.tsx index 6ad38e04..98bcbae5 100644 --- a/apps/portal/src/features/catalog/components/common/FeatureCard.tsx +++ b/apps/portal/src/features/catalog/components/common/FeatureCard.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { AnimatedCard } from "@/components/molecules/AnimatedCard"; +import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard"; export function FeatureCard({ icon, diff --git a/apps/portal/src/features/catalog/components/common/ServiceHeroCard.tsx b/apps/portal/src/features/catalog/components/common/ServiceHeroCard.tsx index 556d5f5d..0574e4f3 100644 --- a/apps/portal/src/features/catalog/components/common/ServiceHeroCard.tsx +++ b/apps/portal/src/features/catalog/components/common/ServiceHeroCard.tsx @@ -1,7 +1,7 @@ "use client"; import React from "react"; -import { AnimatedCard } from "@/components/molecules/AnimatedCard"; +import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard"; import { Button } from "@/components/atoms/button"; import { ArrowRightIcon } from "@heroicons/react/24/outline"; diff --git a/apps/portal/src/features/catalog/components/common/index.ts b/apps/portal/src/features/catalog/components/common/index.ts deleted file mode 100644 index 1cb6f628..00000000 --- a/apps/portal/src/features/catalog/components/common/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from "./ServiceHeroCard"; -export * from "./FeatureCard"; diff --git a/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx b/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx index a8d31207..8901692c 100644 --- a/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx +++ b/apps/portal/src/features/catalog/components/internet/configure/steps/ServiceConfigurationStep.tsx @@ -3,7 +3,7 @@ import { AnimatedCard } from "@/components/molecules"; import { Button } from "@/components/atoms/button"; import { StepHeader } from "@/components/atoms"; -import { AlertBanner } from "@/components/molecules/AlertBanner"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { ArrowRightIcon } from "@heroicons/react/24/outline"; import type { InternetPlanCatalogItem } from "@customer-portal/domain"; import type { AccessMode } from "../../../hooks/useConfigureParams"; diff --git a/apps/portal/src/features/catalog/components/internet/index.ts b/apps/portal/src/features/catalog/components/internet/index.ts deleted file mode 100644 index f7361c9c..00000000 --- a/apps/portal/src/features/catalog/components/internet/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./InternetPlanCard"; diff --git a/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx b/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx index 759170c8..f0d6f0c7 100644 --- a/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx +++ b/apps/portal/src/features/catalog/components/sim/SimPlanCard.tsx @@ -1,7 +1,7 @@ "use client"; import { DevicePhoneMobileIcon, UsersIcon, CurrencyYenIcon } from "@heroicons/react/24/outline"; -import { AnimatedCard } from "@/components/molecules/AnimatedCard"; +import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard"; import { Button } from "@/components/atoms/button"; import type { SimCatalogProduct } from "@customer-portal/domain"; import { getMonthlyPrice } from "../../utils/pricing"; diff --git a/apps/portal/src/features/catalog/components/vpn/index.ts b/apps/portal/src/features/catalog/components/vpn/index.ts deleted file mode 100644 index 6fa68cf8..00000000 --- a/apps/portal/src/features/catalog/components/vpn/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./VpnPlanCard"; diff --git a/apps/portal/src/features/catalog/utils/index.ts b/apps/portal/src/features/catalog/utils/index.ts deleted file mode 100644 index 543bc8f8..00000000 --- a/apps/portal/src/features/catalog/utils/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from "./catalog.utils"; diff --git a/apps/portal/src/features/catalog/views/InternetPlans.tsx b/apps/portal/src/features/catalog/views/InternetPlans.tsx index 7361919f..6bb6b809 100644 --- a/apps/portal/src/features/catalog/views/InternetPlans.tsx +++ b/apps/portal/src/features/catalog/views/InternetPlans.tsx @@ -21,10 +21,10 @@ import type { import { getMonthlyPrice } from "../utils/pricing"; import { LoadingCard, Skeleton, LoadingTable } from "@/components/atoms/loading-skeleton"; import { AnimatedCard } from "@/components/molecules"; -import { AsyncBlock } from "@/components/molecules/AsyncBlock"; +import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { Button } from "@/components/atoms/button"; import { InternetPlanCard } from "@/features/catalog/components/internet/InternetPlanCard"; -import { AlertBanner } from "@/components/molecules/AlertBanner"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; export function InternetPlansContainer() { const { data, isLoading, error } = useInternetCatalog(); diff --git a/apps/portal/src/features/catalog/views/SimPlans.tsx b/apps/portal/src/features/catalog/views/SimPlans.tsx index c60f5c76..ec8090d8 100644 --- a/apps/portal/src/features/catalog/views/SimPlans.tsx +++ b/apps/portal/src/features/catalog/views/SimPlans.tsx @@ -13,7 +13,7 @@ import { } from "@heroicons/react/24/outline"; import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton"; import { Button } from "@/components/atoms/button"; -import { AlertBanner } from "@/components/molecules/AlertBanner"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { useSimCatalog } from "@/features/catalog/hooks"; import type { SimCatalogProduct } from "@customer-portal/domain"; import { SimPlanTypeSection } from "@/features/catalog/components/sim/SimPlanTypeSection"; diff --git a/apps/portal/src/features/catalog/views/VpnPlans.tsx b/apps/portal/src/features/catalog/views/VpnPlans.tsx index d265e247..4e6402c4 100644 --- a/apps/portal/src/features/catalog/views/VpnPlans.tsx +++ b/apps/portal/src/features/catalog/views/VpnPlans.tsx @@ -4,9 +4,9 @@ import { PageLayout } from "@/components/templates/PageLayout"; import { ShieldCheckIcon, ArrowLeftIcon } from "@heroicons/react/24/outline"; import { useVpnCatalog } from "@/features/catalog/hooks"; import { LoadingCard } from "@/components/atoms"; -import { AsyncBlock } from "@/components/molecules/AsyncBlock"; +import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { Button } from "@/components/atoms/button"; -import { AlertBanner } from "@/components/molecules/AlertBanner"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { VpnPlanCard } from "@/features/catalog/components/vpn/VpnPlanCard"; export function VpnPlansView() { diff --git a/apps/portal/src/features/checkout/views/CheckoutContainer.tsx b/apps/portal/src/features/checkout/views/CheckoutContainer.tsx index a56981d3..d1adb1c3 100644 --- a/apps/portal/src/features/checkout/views/CheckoutContainer.tsx +++ b/apps/portal/src/features/checkout/views/CheckoutContainer.tsx @@ -3,8 +3,8 @@ import { useCheckout } from "@/features/checkout/hooks/useCheckout"; import { PageLayout } from "@/components/templates/PageLayout"; import { SubCard } from "@/components/molecules/SubCard/SubCard"; import { Button } from "@/components/atoms/button"; -import { AlertBanner } from "@/components/molecules/AlertBanner"; -import { PageAsync } from "@/components/molecules/AsyncBlock"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; +import { PageAsync } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { InlineToast } from "@/components/atoms/inline-toast"; import { StatusPill } from "@/components/atoms/status-pill"; import { AddressConfirmation } from "@/features/catalog/components/base/AddressConfirmation"; diff --git a/apps/portal/src/features/dashboard/components/PaymentErrorBanner.tsx b/apps/portal/src/features/dashboard/components/PaymentErrorBanner.tsx index a139537d..c6ca8cce 100644 --- a/apps/portal/src/features/dashboard/components/PaymentErrorBanner.tsx +++ b/apps/portal/src/features/dashboard/components/PaymentErrorBanner.tsx @@ -1,6 +1,6 @@ "use client"; -import { AlertBanner } from "@/components/molecules/AlertBanner"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; interface PaymentErrorBannerProps { message: string; diff --git a/apps/portal/src/features/orders/components/index.ts b/apps/portal/src/features/orders/components/index.ts deleted file mode 100644 index 5f0f10d9..00000000 --- a/apps/portal/src/features/orders/components/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export { OrderCard } from "./OrderCard"; -export { OrderCardSkeleton } from "./OrderCardSkeleton"; - - diff --git a/apps/portal/src/features/orders/views/OrdersList.tsx b/apps/portal/src/features/orders/views/OrdersList.tsx index 593c4535..ffac8583 100644 --- a/apps/portal/src/features/orders/views/OrdersList.tsx +++ b/apps/portal/src/features/orders/views/OrdersList.tsx @@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation"; import { PageLayout } from "@/components/templates/PageLayout"; import { ClipboardDocumentListIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; import { AnimatedCard } from "@/components/molecules"; -import { AlertBanner } from "@/components/molecules/AlertBanner"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { ordersService } from "@/features/orders/services/orders.service"; import { OrderCard } from "@/features/orders/components/OrderCard"; import { OrderCardSkeleton } from "@/features/orders/components/OrderCardSkeleton"; diff --git a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx index 31ae0db1..9fefaf0c 100644 --- a/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx +++ b/apps/portal/src/features/subscriptions/views/SimChangePlan.tsx @@ -7,7 +7,7 @@ import { PageLayout } from "@/components/templates/PageLayout"; import { SubCard } from "@/components/molecules/SubCard/SubCard"; import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline"; import { simActionsService } from "@/features/subscriptions/services/sim-actions.service"; -import { AlertBanner } from "@/components/molecules/AlertBanner"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const; type PlanCode = (typeof PLAN_CODES)[number]; diff --git a/apps/portal/src/features/subscriptions/views/SimTopUp.tsx b/apps/portal/src/features/subscriptions/views/SimTopUp.tsx index 7e23cd2d..8aeafd6c 100644 --- a/apps/portal/src/features/subscriptions/views/SimTopUp.tsx +++ b/apps/portal/src/features/subscriptions/views/SimTopUp.tsx @@ -6,7 +6,7 @@ import { useParams } from "next/navigation"; import { PageLayout } from "@/components/templates/PageLayout"; import { SubCard } from "@/components/molecules/SubCard/SubCard"; import { simActionsService } from "@/features/subscriptions/services/sim-actions.service"; -import { AlertBanner } from "@/components/molecules/AlertBanner"; +import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline"; export function SimTopUpContainer() { diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx index 46a9f006..f92dc9a1 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionDetail.tsx @@ -2,7 +2,7 @@ import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton"; import { SubCard } from "@/components/molecules/SubCard/SubCard"; -import { DetailHeader } from "@/components/molecules/DetailHeader"; +import { DetailHeader } from "@/components/molecules/DetailHeader/DetailHeader"; import { useEffect, useState } from "react"; import { useParams, useSearchParams } from "next/navigation"; import Link from "next/link"; diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx index ea52a0aa..2cf044a1 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx @@ -12,7 +12,7 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard"; import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar"; import { LoadingTable } from "@/components/atoms/loading-skeleton"; import { ErrorState } from "@/components/atoms/error-state"; -import { AsyncBlock } from "@/components/molecules/AsyncBlock"; +import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { ServerIcon, CheckCircleIcon, diff --git a/packages/domain/src/validation/forms/profile.ts b/packages/domain/src/validation/forms/profile.ts index 66942971..62be5f98 100644 --- a/packages/domain/src/validation/forms/profile.ts +++ b/packages/domain/src/validation/forms/profile.ts @@ -17,6 +17,11 @@ export const profileEditFormSchema = updateProfileRequestSchema.extend({ lastName: nameSchema, }); +// Profile display schema includes non-editable fields like email +export const profileDisplaySchema = profileEditFormSchema.extend({ + email: emailSchema, // Read-only field for display +}); + // Use required address schema for forms where address is mandatory export const addressFormSchema = requiredAddressSchema; @@ -65,8 +70,12 @@ import type { UpdateAddressRequest as UpdateAddressRequestData, } from "../api/requests"; +// Import email schema for display type +import { emailSchema } from "../shared/primitives"; + // Export form types and API request types export type ProfileEditFormData = z.infer; +export type ProfileDisplayData = z.infer; export type AddressFormData = z.infer; export type ContactFormData = z.infer; diff --git a/packages/domain/src/validation/index.ts b/packages/domain/src/validation/index.ts index 80bf1084..22e483f0 100644 --- a/packages/domain/src/validation/index.ts +++ b/packages/domain/src/validation/index.ts @@ -16,6 +16,9 @@ // Shared validation modules (modular architecture) export * from "./shared"; +// Export specific billing cycle types for convenience +export type { SubscriptionBillingCycleSchema as SubscriptionBillingCycle } from "./shared/primitives"; + // API request schemas (backend) - explicit exports for better tree shaking export { // Auth API schemas @@ -109,11 +112,13 @@ export { export { // Profile form schemas profileEditFormSchema, + profileDisplaySchema, addressFormSchema, contactFormSchema, // Profile form types type ProfileEditFormData, + type ProfileDisplayData, type AddressFormData, type ContactFormData, @@ -152,5 +157,19 @@ export { type PaymentMethodValidation, } from "./business"; +// Order validation schemas and types +export { + orderItemProductSchema, + orderDetailItemSchema, + orderSummaryItemSchema, + orderDetailsSchema, + orderSummarySchema, + type OrderItemProduct, + type OrderDetailItem, + type OrderItemSummary, + type OrderDetailsResponse, + type OrderSummaryResponse, +} from "./shared/order"; + // Simple validation utilities (direct Zod usage) export { z, parseOrThrow, safeParse } from "./shared/utilities"; diff --git a/packages/domain/src/validation/shared/primitives.ts b/packages/domain/src/validation/shared/primitives.ts index 209f8f2d..0dfe3c69 100644 --- a/packages/domain/src/validation/shared/primitives.ts +++ b/packages/domain/src/validation/shared/primitives.ts @@ -96,6 +96,10 @@ export const statusEnum = z.enum(["active", "inactive", "pending", "suspended"]) export const priorityEnum = z.enum(["low", "medium", "high", "urgent"]); export const categoryEnum = z.enum(["technical", "billing", "account", "general"]); +// Billing cycle enums +export const billingCycleEnum = z.enum(["Monthly", "Quarterly", "Annually", "Onetime", "Free"]); +export const subscriptionBillingCycleEnum = z.enum(["Monthly", "Quarterly", "Annually", "Biennially", "Triennially"]); + // ===================================================== // TYPE EXPORTS // ===================================================== @@ -115,3 +119,5 @@ export type GenderSchema = z.infer; export type StatusSchema = z.infer; export type PrioritySchema = z.infer; export type CategorySchema = z.infer; +export type BillingCycleSchema = z.infer; +export type SubscriptionBillingCycleSchema = z.infer;