diff --git a/apps/bff/src/auth/auth.controller.ts b/apps/bff/src/auth/auth.controller.ts index d9b6d880..9f8dc1b6 100644 --- a/apps/bff/src/auth/auth.controller.ts +++ b/apps/bff/src/auth/auth.controller.ts @@ -12,10 +12,7 @@ import { ChangePasswordDto } from "./dto/change-password.dto"; import { LinkWhmcsDto } from "./dto/link-whmcs.dto"; import { SetPasswordDto } from "./dto/set-password.dto"; import { ValidateSignupDto } from "./dto/validate-signup.dto"; -import { - AccountStatusRequestDto, - AccountStatusResponseDto, -} from "./dto/account-status.dto"; +import { AccountStatusRequestDto, AccountStatusResponseDto } from "./dto/account-status.dto"; import { Public } from "./decorators/public.decorator"; @ApiTags("auth") @@ -48,9 +45,7 @@ export class AuthController { @Post("account-status") @ApiOperation({ summary: "Get account status by email" }) @ApiResponse({ status: 200, description: "Account status", type: Object }) - async accountStatus( - @Body() body: AccountStatusRequestDto - ): Promise { + async accountStatus(@Body() body: AccountStatusRequestDto): Promise { return this.authService.getAccountStatus(body.email); } diff --git a/apps/bff/src/auth/auth.service.ts b/apps/bff/src/auth/auth.service.ts index acf35c41..667d95f2 100644 --- a/apps/bff/src/auth/auth.service.ts +++ b/apps/bff/src/auth/auth.service.ts @@ -484,13 +484,9 @@ export class AuthService { // 1.a If this WHMCS client is already mapped, direct the user to sign in instead try { - const existingMapping = await this.mappingsService.findByWhmcsClientId( - clientDetails.id - ); + const existingMapping = await this.mappingsService.findByWhmcsClientId(clientDetails.id); if (existingMapping) { - throw new ConflictException( - "This billing account is already linked. Please sign in." - ); + throw new ConflictException("This billing account is already linked. Please sign in."); } } catch (mapErr) { if (mapErr instanceof ConflictException) throw mapErr; @@ -959,7 +955,10 @@ export class AuthService { } // Validate new password strength (reusing signup policy) - if (newPassword.length < 8 || !/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/.test(newPassword)) { + if ( + newPassword.length < 8 || + !/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/.test(newPassword) + ) { throw new BadRequestException( "Password must be at least 8 characters and include uppercase, lowercase, number, and special character." ); diff --git a/apps/bff/src/auth/dto/account-status.dto.ts b/apps/bff/src/auth/dto/account-status.dto.ts index 2b714975..427fbb55 100644 --- a/apps/bff/src/auth/dto/account-status.dto.ts +++ b/apps/bff/src/auth/dto/account-status.dto.ts @@ -18,4 +18,3 @@ export interface AccountStatusResponseDto { needsPasswordSet?: boolean; recommendedAction: RecommendedAction; } - diff --git a/apps/bff/src/auth/dto/change-password.dto.ts b/apps/bff/src/auth/dto/change-password.dto.ts index 5559b0b8..fc76ab51 100644 --- a/apps/bff/src/auth/dto/change-password.dto.ts +++ b/apps/bff/src/auth/dto/change-password.dto.ts @@ -13,4 +13,3 @@ export class ChangePasswordDto { @Matches(/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]*$/) newPassword!: string; } - diff --git a/apps/bff/src/common/logging/logging.module.ts b/apps/bff/src/common/logging/logging.module.ts index a49b71a3..4ce8d85c 100644 --- a/apps/bff/src/common/logging/logging.module.ts +++ b/apps/bff/src/common/logging/logging.module.ts @@ -8,35 +8,37 @@ import { LoggerModule } from "nestjs-pino"; pinoHttp: { level: process.env.LOG_LEVEL || "info", name: process.env.APP_NAME || "customer-portal-bff", - + // Reduce HTTP request/response noise autoLogging: { - ignore: (req) => { + ignore: req => { // Skip logging for health checks and static assets - const url = req.url || ''; - return url.includes('/health') || - url.includes('/favicon') || - url.includes('/_next/') || - url.includes('/api/auth/session'); // Skip frequent session checks - } + const url = req.url || ""; + return ( + url.includes("/health") || + url.includes("/favicon") || + url.includes("/_next/") || + url.includes("/api/auth/session") + ); // Skip frequent session checks + }, }, - + // Custom serializers to reduce response body logging serializers: { - req: (req) => ({ + req: (req: { method?: string; url?: string; headers?: Record }) => ({ method: req.method, url: req.url, // Don't log headers or body in production - ...(process.env.NODE_ENV === 'development' && { - headers: req.headers - }) + ...(process.env.NODE_ENV === "development" && { + headers: req.headers, + }), }), - res: (res) => ({ + res: (res: { statusCode?: number }) => ({ statusCode: res.statusCode, // Don't log response body to reduce noise - }) + }), }, - + transport: process.env.NODE_ENV === "development" ? { diff --git a/apps/bff/src/orders/controllers/order-fulfillment.controller.ts b/apps/bff/src/orders/controllers/order-fulfillment.controller.ts new file mode 100644 index 00000000..28cc8795 --- /dev/null +++ b/apps/bff/src/orders/controllers/order-fulfillment.controller.ts @@ -0,0 +1,164 @@ +import { + Controller, + Post, + Param, + Body, + Headers, + HttpCode, + HttpStatus, + UseGuards, +} from "@nestjs/common"; +import { ApiTags, ApiOperation, ApiParam, ApiResponse, ApiHeader } from "@nestjs/swagger"; +import { ThrottlerGuard } from "@nestjs/throttler"; +import { Logger } from "nestjs-pino"; +import { Public } from "../../auth/decorators/public.decorator"; +import { EnhancedWebhookSignatureGuard } from "../../webhooks/guards/enhanced-webhook-signature.guard"; +import { OrderFulfillmentService } from "../services/order-fulfillment.service"; +import type { OrderFulfillmentRequest } from "../services/order-fulfillment.service"; + +@ApiTags("order-fulfillment") +@Controller("orders") +@Public() // Salesforce webhook uses signature-based auth, not JWT +export class OrderFulfillmentController { + constructor( + private readonly orderFulfillmentService: OrderFulfillmentService, + private readonly logger: Logger + ) {} + + @Post(":sfOrderId/fulfill") + @HttpCode(HttpStatus.OK) + @UseGuards(ThrottlerGuard, EnhancedWebhookSignatureGuard) + @ApiOperation({ + summary: "Fulfill order from Salesforce", + description: + "Secure endpoint called by Salesforce Quick Action to fulfill orders in WHMCS. Handles complete flow: SF Order β†’ WHMCS AddOrder/AcceptOrder β†’ SF Status Update", + }) + @ApiParam({ + name: "sfOrderId", + type: String, + description: "Salesforce Order ID to provision", + example: "8014x000000ABCDXYZ", + }) + @ApiHeader({ + name: "X-SF-Signature", + description: "HMAC-SHA256 signature of request body using shared secret", + required: true, + example: "a1b2c3d4e5f6...", + }) + @ApiHeader({ + name: "X-SF-Timestamp", + description: "ISO timestamp of request (max 5 minutes old)", + required: true, + example: "2024-01-15T10:30:00Z", + }) + @ApiHeader({ + name: "X-SF-Nonce", + description: "Unique nonce to prevent replay attacks", + required: true, + example: "abc123def456", + }) + @ApiHeader({ + name: "Idempotency-Key", + description: "Unique key for safe retries", + required: true, + example: "provision_8014x000000ABCDXYZ_1705312200000", + }) + @ApiResponse({ + status: 200, + description: "Order provisioning completed successfully", + schema: { + type: "object", + properties: { + success: { type: "boolean", example: true }, + status: { + type: "string", + enum: ["Provisioned", "Already Provisioned"], + example: "Provisioned", + }, + whmcsOrderId: { type: "string", example: "12345" }, + whmcsServiceIds: { type: "array", items: { type: "number" }, example: [67890, 67891] }, + message: { type: "string", example: "Order provisioned successfully in WHMCS" }, + }, + }, + }) + @ApiResponse({ + status: 400, + description: "Invalid request or order not found", + schema: { + type: "object", + properties: { + success: { type: "boolean", example: false }, + status: { type: "string", example: "Failed" }, + message: { type: "string", example: "Salesforce order not found" }, + errorCode: { type: "string", example: "ORDER_NOT_FOUND" }, + }, + }, + }) + @ApiResponse({ + status: 401, + description: "Invalid signature or authentication", + }) + @ApiResponse({ + status: 409, + description: "Payment method missing or other conflict", + schema: { + type: "object", + properties: { + success: { type: "boolean", example: false }, + status: { type: "string", example: "Failed" }, + message: { + type: "string", + example: "Payment method missing - client must add payment method before provisioning", + }, + errorCode: { type: "string", example: "PAYMENT_METHOD_MISSING" }, + }, + }, + }) + async fulfillOrder( + @Param("sfOrderId") sfOrderId: string, + @Body() payload: OrderFulfillmentRequest, + @Headers("idempotency-key") idempotencyKey: string + ) { + this.logger.log("Salesforce fulfillment request received", { + sfOrderId, + idempotencyKey, + timestamp: payload.timestamp, + hasNonce: Boolean(payload.nonce), + }); + + try { + const result = await this.orderFulfillmentService.fulfillOrder( + sfOrderId, + payload, + idempotencyKey + ); + + this.logger.log("Salesforce provisioning completed", { + sfOrderId, + success: result.success, + status: result.status, + whmcsOrderId: result.whmcsOrderId, + serviceCount: result.whmcsServiceIds?.length || 0, + }); + + return { + success: result.success, + status: result.status, + whmcsOrderId: result.whmcsOrderId, + whmcsServiceIds: result.whmcsServiceIds, + message: result.message, + ...(result.errorCode && { errorCode: result.errorCode }), + timestamp: new Date().toISOString(), + }; + } catch (error) { + this.logger.error("Salesforce provisioning failed", { + error: error instanceof Error ? error.message : String(error), + sfOrderId, + idempotencyKey, + }); + + // Re-throw to let global exception handler format the response + throw error; + } + } +} diff --git a/apps/bff/src/orders/orders.controller.ts b/apps/bff/src/orders/orders.controller.ts index 3a2cf067..dfcf341c 100644 --- a/apps/bff/src/orders/orders.controller.ts +++ b/apps/bff/src/orders/orders.controller.ts @@ -58,11 +58,6 @@ export class OrdersController { return this.orderOrchestrator.getOrder(sfOrderId); } - @ApiBearerAuth() - @Post(":sfOrderId/provision") - @ApiOperation({ summary: "Trigger provisioning for an approved order" }) - @ApiParam({ name: "sfOrderId", type: String }) - async provision(@Request() req: RequestWithUser, @Param("sfOrderId") sfOrderId: string) { - return this.orderOrchestrator.provisionOrder(req.user.id, sfOrderId); - } + // Note: Order provisioning has been moved to SalesforceProvisioningController + // This controller now focuses only on customer-facing order operations } diff --git a/apps/bff/src/orders/orders.module.ts b/apps/bff/src/orders/orders.module.ts index bde4b196..50923abf 100644 --- a/apps/bff/src/orders/orders.module.ts +++ b/apps/bff/src/orders/orders.module.ts @@ -1,5 +1,6 @@ import { Module } from "@nestjs/common"; import { OrdersController } from "./orders.controller"; +import { OrderFulfillmentController } from "./controllers/order-fulfillment.controller"; import { VendorsModule } from "../vendors/vendors.module"; import { MappingsModule } from "../mappings/mappings.module"; import { UsersModule } from "../users/users.module"; @@ -10,16 +11,28 @@ import { OrderBuilder } from "./services/order-builder.service"; import { OrderItemBuilder } from "./services/order-item-builder.service"; import { OrderOrchestrator } from "./services/order-orchestrator.service"; +// Clean modular fulfillment services +import { OrderFulfillmentService } from "./services/order-fulfillment.service"; +import { OrderFulfillmentValidator } from "./services/order-fulfillment-validator.service"; +import { OrderWhmcsMapper } from "./services/order-whmcs-mapper.service"; +import { OrderFulfillmentOrchestrator } from "./services/order-fulfillment-orchestrator.service"; + @Module({ imports: [VendorsModule, MappingsModule, UsersModule], - controllers: [OrdersController], + controllers: [OrdersController, OrderFulfillmentController], providers: [ - // Clean architecture only + // Order creation services (modular) OrderValidator, OrderBuilder, OrderItemBuilder, OrderOrchestrator, + + // Order fulfillment services (modular) + OrderFulfillmentValidator, + OrderWhmcsMapper, + OrderFulfillmentOrchestrator, + OrderFulfillmentService, ], - exports: [OrderOrchestrator], + exports: [OrderOrchestrator, OrderFulfillmentService], }) export class OrdersModule {} diff --git a/apps/bff/src/orders/services/order-fulfillment-error.service.ts b/apps/bff/src/orders/services/order-fulfillment-error.service.ts new file mode 100644 index 00000000..beb6e4b4 --- /dev/null +++ b/apps/bff/src/orders/services/order-fulfillment-error.service.ts @@ -0,0 +1,97 @@ +import { Injectable } from "@nestjs/common"; + +export enum OrderFulfillmentErrorCode { + PAYMENT_METHOD_MISSING = "PAYMENT_METHOD_MISSING", + ORDER_NOT_FOUND = "ORDER_NOT_FOUND", + WHMCS_ERROR = "WHMCS_ERROR", + MAPPING_ERROR = "MAPPING_ERROR", + VALIDATION_ERROR = "VALIDATION_ERROR", + SALESFORCE_ERROR = "SALESFORCE_ERROR", + PROVISIONING_ERROR = "PROVISIONING_ERROR", +} + +/** + * Centralized error code determination and error handling for order fulfillment + * Eliminates duplicate error code logic across services + */ +@Injectable() +export class OrderFulfillmentErrorService { + /** + * Determine error code from error object or message + */ + determineErrorCode(error: unknown): OrderFulfillmentErrorCode { + const errorMessage = this.getErrorMessage(error); + + if (errorMessage.includes("Payment method missing")) { + return OrderFulfillmentErrorCode.PAYMENT_METHOD_MISSING; + } + if (errorMessage.includes("not found")) { + return OrderFulfillmentErrorCode.ORDER_NOT_FOUND; + } + if (errorMessage.includes("WHMCS")) { + return OrderFulfillmentErrorCode.WHMCS_ERROR; + } + if (errorMessage.includes("mapping")) { + return OrderFulfillmentErrorCode.MAPPING_ERROR; + } + if (errorMessage.includes("validation") || errorMessage.includes("Invalid")) { + return OrderFulfillmentErrorCode.VALIDATION_ERROR; + } + if (errorMessage.includes("Salesforce") || errorMessage.includes("SF")) { + return OrderFulfillmentErrorCode.SALESFORCE_ERROR; + } + + return OrderFulfillmentErrorCode.PROVISIONING_ERROR; + } + + /** + * Get user-friendly error message for external consumption + * Ensures no sensitive information is exposed + */ + getUserFriendlyMessage(error: unknown, errorCode: OrderFulfillmentErrorCode): string { + switch (errorCode) { + case OrderFulfillmentErrorCode.PAYMENT_METHOD_MISSING: + return "Payment method missing - please add a payment method before fulfillment"; + case OrderFulfillmentErrorCode.ORDER_NOT_FOUND: + return "Order not found or cannot be fulfilled"; + case OrderFulfillmentErrorCode.WHMCS_ERROR: + return "Billing system error - please try again later"; + case OrderFulfillmentErrorCode.MAPPING_ERROR: + return "Order configuration error - please contact support"; + case OrderFulfillmentErrorCode.VALIDATION_ERROR: + return "Invalid order data - please verify order details"; + case OrderFulfillmentErrorCode.SALESFORCE_ERROR: + return "CRM system error - please try again later"; + default: + return "Order fulfillment failed - please contact support"; + } + } + + /** + * Extract error message from various error types + */ + private getErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + return String(error); + } + + /** + * Create standardized error response + */ + createErrorResponse(error: unknown) { + const errorCode = this.determineErrorCode(error); + const userMessage = this.getUserFriendlyMessage(error, errorCode); + + return { + success: false, + status: "Failed" as const, + message: userMessage, + errorCode: errorCode, + }; + } +} diff --git a/apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts new file mode 100644 index 00000000..8773304a --- /dev/null +++ b/apps/bff/src/orders/services/order-fulfillment-orchestrator.service.ts @@ -0,0 +1,373 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { SalesforceService } from "../../vendors/salesforce/salesforce.service"; +import { WhmcsOrderService, WhmcsOrderResult } from "../../vendors/whmcs/services/whmcs-order.service"; +import { OrderOrchestrator } from "./order-orchestrator.service"; +import { OrderFulfillmentValidator, OrderFulfillmentValidationResult } from "./order-fulfillment-validator.service"; +import { OrderWhmcsMapper, OrderItemMappingResult } from "./order-whmcs-mapper.service"; +import { getErrorMessage } from "../../common/utils/error.util"; + + +export interface OrderFulfillmentStep { + step: string; + status: "pending" | "in_progress" | "completed" | "failed"; + startedAt?: Date; + completedAt?: Date; + error?: string; +} + +export interface OrderFulfillmentContext { + sfOrderId: string; + idempotencyKey: string; + validation: OrderFulfillmentValidationResult; + orderDetails?: any; // OrderOrchestrator.getOrder() returns transformed structure + mappingResult?: OrderItemMappingResult; + whmcsResult?: WhmcsOrderResult; + steps: OrderFulfillmentStep[]; +} + + + +/** + * Orchestrates the complete order fulfillment workflow + * Similar to OrderOrchestrator but for fulfillment operations + */ +@Injectable() +export class OrderFulfillmentOrchestrator { + constructor( + @Inject(Logger) private readonly logger: Logger, + private readonly salesforceService: SalesforceService, + private readonly whmcsOrderService: WhmcsOrderService, + private readonly orderOrchestrator: OrderOrchestrator, + private readonly orderFulfillmentValidator: OrderFulfillmentValidator, + private readonly orderWhmcsMapper: OrderWhmcsMapper + ) {} + + /** + * Execute complete fulfillment workflow + */ + async executeFulfillment( + sfOrderId: string, + payload: Record, + idempotencyKey: string + ): Promise { + const context: OrderFulfillmentContext = { + sfOrderId, + idempotencyKey, + validation: {} as OrderFulfillmentValidationResult, + steps: this.initializeSteps(), + }; + + this.logger.log("Starting fulfillment orchestration", { + sfOrderId, + idempotencyKey, + }); + + try { + // Step 1: Validate fulfillment request + await this.executeStep(context, "validation", async () => { + context.validation = await this.orderFulfillmentValidator.validateFulfillmentRequest( + sfOrderId, + idempotencyKey + ); + }); + + // If already provisioned, return early + if (context.validation.isAlreadyProvisioned) { + this.markStepCompleted(context, "validation"); + this.markStepsSkipped(context, [ + "sf_status_update", + "order_details", + "mapping", + "whmcs_create", + "whmcs_accept", + "sf_success_update", + ]); + return context; + } + + // Step 2: Update Salesforce status to "Activating" + await this.executeStep(context, "sf_status_update", async () => { + await this.salesforceService.updateOrder({ + Id: sfOrderId, + Provisioning_Status__c: "Activating", + Last_Provisioning_At__c: new Date().toISOString(), + }); + }); + + // Step 3: Get order details with items + await this.executeStep(context, "order_details", async () => { + const orderDetails = await this.orderOrchestrator.getOrder(sfOrderId); + if (!orderDetails) { + // Do not expose sensitive info in error + throw new Error("Order details could not be retrieved."); + } + context.orderDetails = orderDetails; + }); + + // Step 4: Map OrderItems to WHMCS format + await this.executeStep(context, "mapping", async () => { + if (!context.orderDetails) { + throw new Error("Order details are required for mapping"); + } + + if (!context.orderDetails.items || !Array.isArray(context.orderDetails.items)) { + throw new Error("Order items must be an array"); + } + + context.mappingResult = await this.orderWhmcsMapper.mapOrderItemsToWhmcs(context.orderDetails.items); + + // Validate mapped items + this.orderWhmcsMapper.validateMappedItems(context.mappingResult.whmcsItems); + }); + + // Step 5: Create order in WHMCS + await this.executeStep(context, "whmcs_create", async () => { + const orderNotes = this.orderWhmcsMapper.createOrderNotes( + sfOrderId, + `Provisioned from Salesforce Order ${sfOrderId}` + ); + + const createResult = await this.whmcsOrderService.addOrder({ + clientId: context.validation.clientId, + items: context.mappingResult!.whmcsItems, + paymentMethod: "mailin", // Default payment method for provisioning orders + sfOrderId, + notes: orderNotes, + noinvoice: true, // Don't create invoice during provisioning + noemail: true, // Don't send emails during provisioning + }); + + context.whmcsResult = { + orderId: createResult.orderId, + serviceIds: [], // Will be populated in accept step + }; + }); + + // Step 6: Accept/provision order in WHMCS + await this.executeStep(context, "whmcs_accept", async () => { + const acceptResult = await this.whmcsOrderService.acceptOrder( + context.whmcsResult!.orderId, + sfOrderId + ); + + // Update context with complete WHMCS result + context.whmcsResult = acceptResult; + }); + + // Step 7: Update Salesforce with success + await this.executeStep(context, "sf_success_update", async () => { + await this.salesforceService.updateOrder({ + Id: sfOrderId, + Provisioning_Status__c: "Provisioned", + WHMCS_Order_ID__c: context.whmcsResult!.orderId.toString(), + Last_Provisioning_At__c: new Date().toISOString(), + }); + }); + + this.logger.log("Fulfillment orchestration completed successfully", { + sfOrderId, + whmcsOrderId: context.whmcsResult?.orderId, + serviceCount: context.whmcsResult?.serviceIds.length || 0, + totalSteps: context.steps.length, + completedSteps: context.steps.filter(s => s.status === "completed").length, + }); + + return context; + } catch (error) { + await this.handleFulfillmentError( + context, + error instanceof Error ? error : new Error(String(error)) + ); + throw error; + } + } + + /** + * Initialize fulfillment steps + */ + private initializeSteps(): OrderFulfillmentStep[] { + return [ + { step: "validation", status: "pending" }, + { step: "sf_status_update", status: "pending" }, + { step: "order_details", status: "pending" }, + { step: "mapping", status: "pending" }, + { step: "whmcs_create", status: "pending" }, + { step: "whmcs_accept", status: "pending" }, + { step: "sf_success_update", status: "pending" }, + ]; + } + + /** + * Execute a single fulfillment step with error handling + */ + private async executeStep( + context: OrderFulfillmentContext, + stepName: string, + stepFunction: () => Promise + ): Promise { + const step = context.steps.find(s => s.step === stepName); + if (!step) { + throw new Error(`Step ${stepName} not found in context`); + } + + step.status = "in_progress"; + step.startedAt = new Date(); + + this.logger.log(`Executing fulfillment step: ${stepName}`, { + sfOrderId: context.sfOrderId, + step: stepName, + }); + + try { + await stepFunction(); + + step.status = "completed"; + step.completedAt = new Date(); + + this.logger.log(`Fulfillment step completed: ${stepName}`, { + sfOrderId: context.sfOrderId, + step: stepName, + duration: step.completedAt.getTime() - step.startedAt.getTime(), + }); + } catch (error) { + step.status = "failed"; + step.completedAt = new Date(); + step.error = getErrorMessage(error); + + this.logger.error(`Fulfillment step failed: ${stepName}`, { + sfOrderId: context.sfOrderId, + step: stepName, + error: step.error, + }); + + throw error; + } + } + + /** + * Mark step as completed (for skipped steps) + */ + private markStepCompleted(context: OrderFulfillmentContext, stepName: string): void { + const step = context.steps.find(s => s.step === stepName); + if (step) { + step.status = "completed"; + step.completedAt = new Date(); + } + } + + /** + * Mark multiple steps as skipped + */ + private markStepsSkipped(context: OrderFulfillmentContext, stepNames: string[]): void { + stepNames.forEach(stepName => { + const step = context.steps.find(s => s.step === stepName); + if (step) { + step.status = "completed"; // Mark as completed since they're not needed + step.completedAt = new Date(); + } + }); + } + + /** + * Handle fulfillment errors and update Salesforce + */ + private async handleFulfillmentError( + context: OrderFulfillmentContext, + error: Error + ): Promise { + const errorCode = this.determineErrorCode(error); + const userMessage = error.message; + + this.logger.error("Fulfillment orchestration failed", { + sfOrderId: context.sfOrderId, + idempotencyKey: context.idempotencyKey, + error: error.message, + errorCode, + failedStep: context.steps.find((s: OrderFulfillmentStep) => s.status === "failed")?.step, + }); + + // Try to update Salesforce with failure status + try { + await this.salesforceService.updateOrder({ + Id: context.sfOrderId, + Provisioning_Status__c: "Failed", + Provisioning_Error_Code__c: errorCode, + Provisioning_Error_Message__c: userMessage.substring(0, 255), + Last_Provisioning_At__c: new Date().toISOString(), + }); + + this.logger.log("Salesforce updated with failure status", { + sfOrderId: context.sfOrderId, + errorCode, + }); + } catch (updateError) { + this.logger.error("Failed to update Salesforce with error status", { + sfOrderId: context.sfOrderId, + updateError: updateError instanceof Error ? updateError.message : String(updateError), + }); + } + } + + /** + * Determine error code based on error type + */ + private determineErrorCode(error: Error): string { + if (error.message.includes("Payment method missing")) { + return "PAYMENT_METHOD_MISSING"; + } + if (error.message.includes("not found")) { + return "ORDER_NOT_FOUND"; + } + if (error.message.includes("WHMCS")) { + return "WHMCS_ERROR"; + } + if (error.message.includes("mapping")) { + return "MAPPING_ERROR"; + } + return "FULFILLMENT_ERROR"; + } + + /** + * Get fulfillment summary from context + */ + getFulfillmentSummary(context: OrderFulfillmentContext): { + success: boolean; + status: "Already Fulfilled" | "Fulfilled" | "Failed"; + whmcsOrderId?: string; + whmcsServiceIds?: number[]; + message: string; + steps: OrderFulfillmentStep[]; + } { + const isSuccess = context.steps.every((s: OrderFulfillmentStep) => s.status === "completed"); + const failedStep = context.steps.find((s: OrderFulfillmentStep) => s.status === "failed"); + + if (context.validation.isAlreadyProvisioned) { + return { + success: true, + status: "Already Fulfilled", + whmcsOrderId: context.validation.whmcsOrderId, + message: "Order was already fulfilled in WHMCS", + steps: context.steps, + }; + } + + if (isSuccess) { + return { + success: true, + status: "Fulfilled", + whmcsOrderId: context.whmcsResult?.orderId.toString(), + whmcsServiceIds: context.whmcsResult?.serviceIds, + message: "Order fulfilled successfully in WHMCS", + steps: context.steps, + }; + } + + return { + success: false, + status: "Failed", + message: failedStep?.error || "Fulfillment failed", + steps: context.steps, + }; + } +} diff --git a/apps/bff/src/orders/services/order-fulfillment-validator.service.ts b/apps/bff/src/orders/services/order-fulfillment-validator.service.ts new file mode 100644 index 00000000..94abe5f3 --- /dev/null +++ b/apps/bff/src/orders/services/order-fulfillment-validator.service.ts @@ -0,0 +1,218 @@ +import { Injectable, BadRequestException, ConflictException, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { SalesforceService } from "../../vendors/salesforce/salesforce.service"; +import { WhmcsOrderService } from "../../vendors/whmcs/services/whmcs-order.service"; +import { MappingsService } from "../../mappings/mappings.service"; +import { getErrorMessage } from "../../common/utils/error.util"; +import { SalesforceOrder } from "../types/salesforce-order.types"; + +export interface OrderFulfillmentValidationResult { + sfOrder: SalesforceOrder; + clientId: number; + isAlreadyProvisioned: boolean; + whmcsOrderId?: string; +} + +/** + * Handles all order fulfillment validation logic + * Similar to OrderValidator but for fulfillment workflow + */ +@Injectable() +export class OrderFulfillmentValidator { + constructor( + @Inject(Logger) private readonly logger: Logger, + private readonly salesforceService: SalesforceService, + private readonly whmcsOrderService: WhmcsOrderService, + private readonly mappingsService: MappingsService + ) {} + + /** + * Complete validation for fulfillment request + * Validates SF order, gets client ID, checks payment method, checks idempotency + */ + async validateFulfillmentRequest( + sfOrderId: string, + idempotencyKey: string + ): Promise { + this.logger.log("Starting fulfillment validation", { + sfOrderId, + idempotencyKey, + }); + + try { + // 1. Validate Salesforce order exists and get details + const sfOrder = await this.validateSalesforceOrder(sfOrderId); + + // 2. Check if already provisioned (idempotency) + if (sfOrder.WHMCS_Order_ID__c) { + this.logger.log("Order already provisioned", { + sfOrderId, + whmcsOrderId: sfOrder.WHMCS_Order_ID__c, + }); + + return { + sfOrder, + clientId: 0, // Not needed for already provisioned + isAlreadyProvisioned: true, + whmcsOrderId: sfOrder.WHMCS_Order_ID__c, + }; + } + + // 3. Get WHMCS client ID from account mapping + const clientId = await this.getWhmcsClientId(sfOrder.Account.Id); + + // 4. Validate payment method exists + await this.validatePaymentMethod(clientId); + + this.logger.log("Fulfillment validation completed successfully", { + sfOrderId, + clientId, + accountId: sfOrder.Account.Id, + }); + + return { + sfOrder, + clientId, + isAlreadyProvisioned: false, + }; + } catch (error) { + this.logger.error("Fulfillment validation failed", { + error: getErrorMessage(error), + sfOrderId, + idempotencyKey, + }); + throw error; + } + } + + /** + * Validate Salesforce order exists and is in valid state + */ + private async validateSalesforceOrder(sfOrderId: string): Promise { + const order = await this.salesforceService.getOrder(sfOrderId); + + if (!order) { + throw new BadRequestException(`Salesforce order ${sfOrderId} not found`); + } + + // Cast to SalesforceOrder for type safety + const salesforceOrder = order as unknown as SalesforceOrder; + + // Validate order is in a state that can be provisioned + if (salesforceOrder.Status === "Cancelled") { + throw new BadRequestException(`Cannot provision cancelled order ${sfOrderId}`); + } + + this.logger.log("Salesforce order validated", { + sfOrderId, + status: salesforceOrder.Status, + activationStatus: salesforceOrder.Activation_Status__c, + accountId: salesforceOrder.Account.Id, + }); + + return salesforceOrder; + } + + /** + * Get WHMCS client ID from Salesforce account ID using mappings + */ + private async getWhmcsClientId(sfAccountId: string): Promise { + try { + // Use existing mappings service to get client ID + const mapping = await this.mappingsService.findBySfAccountId(sfAccountId); + + if (!mapping?.whmcsClientId) { + throw new BadRequestException(`No WHMCS client mapping found for account ${sfAccountId}`); + } + + this.logger.log("WHMCS client mapping found", { + sfAccountId, + whmcsClientId: mapping.whmcsClientId, + }); + + return mapping.whmcsClientId; + } catch (error) { + this.logger.error("Failed to get WHMCS client mapping", { + error: getErrorMessage(error), + sfAccountId, + }); + throw new BadRequestException(`Failed to find WHMCS client for account ${sfAccountId}`); + } + } + + /** + * Validate client has payment method in WHMCS + */ + private async validatePaymentMethod(clientId: number): Promise { + try { + const hasPaymentMethod = await this.whmcsOrderService.hasPaymentMethod(clientId); + + if (!hasPaymentMethod) { + throw new ConflictException( + "Payment method missing - client must add payment method before fulfillment" + ); + } + + this.logger.log("Payment method validation passed", { clientId }); + } catch (error) { + if (error instanceof ConflictException) { + throw error; // Re-throw ConflictException as-is + } + + this.logger.error("Payment method validation failed", { + error: getErrorMessage(error), + clientId, + }); + + throw new ConflictException("Unable to validate payment method - fulfillment cannot proceed"); + } + } + + /** + * Validate provisioning request payload format + */ + validateRequestPayload(payload: unknown): { + orderId: string; + timestamp: string; + nonce: string; + } { + if (!payload || typeof payload !== "object") { + throw new BadRequestException("Invalid request payload"); + } + + const { orderId, timestamp, nonce } = payload as Record; + + if (!orderId || typeof orderId !== "string") { + throw new BadRequestException("Missing or invalid orderId in payload"); + } + + if (!timestamp || typeof timestamp !== "string") { + throw new BadRequestException("Missing or invalid timestamp in payload"); + } + + if (!nonce || typeof nonce !== "string") { + throw new BadRequestException("Missing or invalid nonce in payload"); + } + + // Validate timestamp is recent (additional validation beyond webhook guard) + try { + const requestTime = new Date(timestamp).getTime(); + const now = Date.now(); + const maxAge = 5 * 60 * 1000; // 5 minutes + + if (Math.abs(now - requestTime) > maxAge) { + throw new BadRequestException("Request timestamp is too old"); + } + } catch { + throw new BadRequestException("Invalid timestamp format"); + } + + this.logger.log("Request payload validated", { + orderId, + timestamp, + hasNonce: Boolean(nonce), + }); + + return { orderId, timestamp, nonce }; + } +} diff --git a/apps/bff/src/orders/services/order-fulfillment.service.ts b/apps/bff/src/orders/services/order-fulfillment.service.ts new file mode 100644 index 00000000..4ed92d61 --- /dev/null +++ b/apps/bff/src/orders/services/order-fulfillment.service.ts @@ -0,0 +1,96 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { OrderFulfillmentOrchestrator } from "./order-fulfillment-orchestrator.service"; +import { OrderFulfillmentValidator } from "./order-fulfillment-validator.service"; +import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service"; +export interface OrderFulfillmentRequest { + orderId: string; + timestamp: string; + nonce: string; +} + +export interface OrderFulfillmentResult { + success: boolean; + status: "Already Fulfilled" | "Fulfilled" | "Failed"; + whmcsOrderId?: string; + whmcsServiceIds?: number[]; + message: string; + errorCode?: string; +} + + + +/** + * Main order fulfillment service - coordinates modular fulfillment components + * Uses clean architecture similar to order creation workflow + */ +@Injectable() +export class OrderFulfillmentService { + constructor( + private readonly orderFulfillmentOrchestrator: OrderFulfillmentOrchestrator, + private readonly orderFulfillmentValidator: OrderFulfillmentValidator, + private readonly orderFulfillmentErrorService: OrderFulfillmentErrorService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Main fulfillment method called by Salesforce webhook + * Uses modular architecture for clean separation of concerns + */ + async fulfillOrder( + sfOrderId: string, + request: OrderFulfillmentRequest, + idempotencyKey: string + ): Promise { + this.logger.log("Starting order fulfillment workflow", { + sfOrderId, + idempotencyKey, + timestamp: request.timestamp, + }); + + try { + // 1. Validate request payload format + const validatedPayload = this.orderFulfillmentValidator.validateRequestPayload(request); + + // 2. Execute complete fulfillment workflow using orchestrator + const context = await this.orderFulfillmentOrchestrator.executeFulfillment( + sfOrderId, + validatedPayload, + idempotencyKey + ); + + // 3. Generate result summary from context + const summary = this.orderFulfillmentOrchestrator.getFulfillmentSummary(context); + + this.logger.log("Order fulfillment workflow completed", { + sfOrderId, + success: summary.success, + status: summary.status, + whmcsOrderId: summary.whmcsOrderId, + serviceCount: summary.whmcsServiceIds?.length || 0, + completedSteps: summary.steps.filter(s => s.status === "completed").length, + totalSteps: summary.steps.length, + }); + + return { + success: summary.success, + status: summary.status, + whmcsOrderId: summary.whmcsOrderId, + whmcsServiceIds: summary.whmcsServiceIds, + message: summary.message, + errorCode: summary.success ? undefined : this.orderFulfillmentErrorService.determineErrorCode(summary.message), + }; + } catch (error) { + this.logger.error("Order fulfillment workflow failed", { + sfOrderId, + idempotencyKey, + error: error instanceof Error ? error.message : String(error), + }); + + // Use centralized error handling service + return this.orderFulfillmentErrorService.createErrorResponse(error); + } + } + + +} diff --git a/apps/bff/src/orders/services/order-orchestrator.service.ts b/apps/bff/src/orders/services/order-orchestrator.service.ts index 58a9b46c..8be73a53 100644 --- a/apps/bff/src/orders/services/order-orchestrator.service.ts +++ b/apps/bff/src/orders/services/order-orchestrator.service.ts @@ -1,4 +1,4 @@ -import { Injectable, Inject, BadRequestException } from "@nestjs/common"; +import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "../../vendors/salesforce/services/salesforce-connection.service"; import { OrderValidator } from "./order-validator.service"; @@ -265,40 +265,6 @@ export class OrderOrchestrator { } } - /** - * Trigger provisioning for an approved order - */ - async provisionOrder(userId: string, orderId: string) { - this.logger.log({ userId, orderId }, "Triggering order provisioning"); - - // Get order and verify it belongs to the user - const order = await this.getOrder(orderId); - if (!order) { - this.logger.warn({ orderId, userId }, "Order not found for provisioning"); - throw new BadRequestException("Order not found"); - } - - // For now, just update the order status to indicate provisioning has started - // In a real implementation, this would trigger the actual provisioning workflow - const soql = ` - UPDATE Order - SET Status = 'Provisioning Started', - Provisioning_Status__c = 'In Progress' - WHERE Id = '${orderId}' - `; - - try { - await this.sf.query(soql); - this.logger.log({ orderId }, "Order provisioning triggered successfully"); - - return { - orderId, - status: "Provisioning Started", - message: "Order provisioning has been initiated", - }; - } catch (error) { - this.logger.error({ error, orderId }, "Failed to trigger order provisioning"); - throw error; - } - } + // Note: Order provisioning has been moved to OrderProvisioningService + // This orchestrator now focuses only on order creation and retrieval } diff --git a/apps/bff/src/orders/services/order-whmcs-mapper.service.ts b/apps/bff/src/orders/services/order-whmcs-mapper.service.ts new file mode 100644 index 00000000..d1ffd2c2 --- /dev/null +++ b/apps/bff/src/orders/services/order-whmcs-mapper.service.ts @@ -0,0 +1,173 @@ +import { Injectable, BadRequestException, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; + +import { WhmcsOrderItem } from "../../vendors/whmcs/services/whmcs-order.service"; +import { getErrorMessage } from "../../common/utils/error.util"; + +export interface OrderItemMappingResult { + whmcsItems: WhmcsOrderItem[]; + summary: { + totalItems: number; + serviceItems: number; + activationItems: number; + }; +} + + + +/** + * Handles mapping Salesforce OrderItems to WHMCS format + * Similar to OrderItemBuilder but for fulfillment workflow + */ +@Injectable() +export class OrderWhmcsMapper { + constructor(@Inject(Logger) private readonly logger: Logger) {} + + /** + * Map Salesforce OrderItems to WHMCS format for provisioning + */ + async mapOrderItemsToWhmcs(orderItems: any[]): Promise { + this.logger.log("Starting OrderItems mapping to WHMCS", { + itemCount: orderItems.length, + }); + + // Validate input before processing + if (!orderItems || orderItems.length === 0) { + throw new BadRequestException("No order items provided for mapping"); + } + + try { + const whmcsItems: WhmcsOrderItem[] = []; + let serviceItems = 0; + let activationItems = 0; + + for (const [index, item] of orderItems.entries()) { + const whmcsItem = this.mapSingleOrderItem(item, index); + whmcsItems.push(whmcsItem); + + // Track item types for summary + if (whmcsItem.billingCycle === "monthly") { + serviceItems++; + } else if (whmcsItem.billingCycle === "onetime") { + activationItems++; + } + } + + const result: OrderItemMappingResult = { + whmcsItems, + summary: { + totalItems: whmcsItems.length, + serviceItems, + activationItems, + }, + }; + + this.logger.log("OrderItems mapping completed successfully", { + totalItems: result.summary.totalItems, + serviceItems: result.summary.serviceItems, + activationItems: result.summary.activationItems, + }); + + return result; + } catch (error) { + this.logger.error("Failed to map OrderItems to WHMCS", { + error: error instanceof Error ? error.message : String(error), + itemCount: orderItems.length, + }); + throw error; + } + } + + /** + * Map a single Salesforce OrderItem to WHMCS format + */ + private mapSingleOrderItem(item: any, index: number): WhmcsOrderItem { + const product = item.product; // This is the transformed structure from OrderOrchestrator + + if (!product) { + throw new BadRequestException(`OrderItem ${index} missing product information`); + } + + if (!product.whmcsProductId) { + throw new BadRequestException( + `Product ${product.id} missing WHMCS Product ID mapping (whmcsProductId)` + ); + } + + // Build WHMCS item - WHMCS products already have their billing cycles configured + const whmcsItem: WhmcsOrderItem = { + productId: product.whmcsProductId, + billingCycle: product.billingCycle.toLowerCase(), // Use the billing cycle from Salesforce OrderItem + quantity: item.quantity || 1, + }; + + this.logger.log("Mapped single OrderItem to WHMCS", { + index, + sfProductId: product.id, + whmcsProductId: product.whmcsProductId, + billingCycle: product.billingCycle, + quantity: whmcsItem.quantity, + }); + + return whmcsItem; + } + + + + + + /** + * Create order notes with Salesforce tracking information + */ + createOrderNotes(sfOrderId: string, additionalNotes?: string): string { + const notes: string[] = []; + + // Always include Salesforce Order ID for tracking + notes.push(`sfOrderId=${sfOrderId}`); + + // Add provisioning timestamp + notes.push(`provisionedAt=${new Date().toISOString()}`); + + // Add additional notes if provided + if (additionalNotes) { + notes.push(additionalNotes); + } + + const finalNotes = notes.join("; "); + + this.logger.log("Created order notes", { + sfOrderId, + hasAdditionalNotes: Boolean(additionalNotes), + notesLength: finalNotes.length, + }); + + return finalNotes; + } + + /** + * Validate mapped WHMCS items before provisioning + */ + validateMappedItems(whmcsItems: WhmcsOrderItem[]): void { + if (!whmcsItems || whmcsItems.length === 0) { + throw new BadRequestException("No items to provision"); + } + + for (const [index, item] of whmcsItems.entries()) { + if (!item.productId) { + throw new BadRequestException(`Item ${index} missing WHMCS product ID`); + } + + if (!item.billingCycle) { + throw new BadRequestException(`Item ${index} missing billing cycle`); + } + + if (!item.quantity || item.quantity < 1) { + throw new BadRequestException(`Item ${index} has invalid quantity: ${item.quantity}`); + } + } + + this.logger.log("WHMCS items validation passed", { + itemCount: whmcsItems.length, + }); + } +} diff --git a/apps/bff/src/users/dto/update-address.dto.ts b/apps/bff/src/users/dto/update-address.dto.ts index c064a6de..c950a434 100644 --- a/apps/bff/src/users/dto/update-address.dto.ts +++ b/apps/bff/src/users/dto/update-address.dto.ts @@ -38,4 +38,3 @@ export class UpdateAddressDto { @Length(0, 100) country?: string; } - diff --git a/apps/bff/src/users/users.service.ts b/apps/bff/src/users/users.service.ts index 3bfab8c2..0e8b0414 100644 --- a/apps/bff/src/users/users.service.ts +++ b/apps/bff/src/users/users.service.ts @@ -83,8 +83,8 @@ export class UsersService { // Helper function to convert Prisma user to EnhancedUser type private toEnhancedUser( - user: PrismaUser, - extras: Partial = {}, + user: PrismaUser, + extras: Partial = {}, salesforceHealthy: boolean = true ): EnhancedUser { return { @@ -224,12 +224,16 @@ export class UsersService { )) as SalesforceAccount | null; if (!account) return this.toEnhancedUser(user, undefined, salesforceHealthy); - return this.toEnhancedUser(user, { - company: account.Name?.trim() || user.company || undefined, - email: user.email, // Keep original email for now - phone: user.phone || undefined, // Keep original phone for now - // Address temporarily disabled until field issues resolved - }, salesforceHealthy); + return this.toEnhancedUser( + user, + { + company: account.Name?.trim() || user.company || undefined, + email: user.email, // Keep original email for now + phone: user.phone || undefined, // Keep original phone for now + // Address temporarily disabled until field issues resolved + }, + salesforceHealthy + ); } catch (error) { salesforceHealthy = false; this.logger.error("Failed to fetch Salesforce account data", { diff --git a/apps/bff/src/vendors/salesforce/salesforce.service.ts b/apps/bff/src/vendors/salesforce/salesforce.service.ts index ab52e0d8..5fc2948e 100644 --- a/apps/bff/src/vendors/salesforce/salesforce.service.ts +++ b/apps/bff/src/vendors/salesforce/salesforce.service.ts @@ -104,6 +104,61 @@ export class SalesforceService implements OnModuleInit { return this.caseService.updateCase(caseId, updates); } + // === ORDER METHODS (For Order Provisioning) === + + async updateOrder(orderData: { Id: string; [key: string]: unknown }): Promise { + try { + if (!this.connection.isConnected()) { + throw new Error("Salesforce connection not available"); + } + + const sobject = this.connection.sobject("Order"); + if (!sobject) { + throw new Error("Failed to get Salesforce Order sobject"); + } + + if (sobject.update) { + await sobject.update(orderData); + } else { + throw new Error("Salesforce Order sobject does not support update operation"); + } + + this.logger.log("Order updated in Salesforce", { + orderId: orderData.Id, + fields: Object.keys(orderData).filter(k => k !== "Id"), + }); + } catch (error) { + this.logger.error("Failed to update order in Salesforce", { + orderId: orderData.Id, + error: getErrorMessage(error), + }); + throw error; + } + } + + async getOrder(orderId: string): Promise | null> { + try { + if (!this.connection.isConnected()) { + throw new Error("Salesforce connection not available"); + } + + const result = (await this.connection.query( + `SELECT Id, Status, Provisioning_Status__c, WHMCS_Order_ID__c, AccountId + FROM Order + WHERE Id = '${orderId}' + LIMIT 1` + )) as { records: Record[]; totalSize: number }; + + return result.records?.[0] || null; + } catch (error) { + this.logger.error("Failed to get order from Salesforce", { + orderId, + error: getErrorMessage(error), + }); + throw error; + } + } + // === HEALTH CHECK === healthCheck(): boolean { diff --git a/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts b/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts index b18cf494..e91a9ff4 100644 --- a/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts +++ b/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts @@ -31,7 +31,7 @@ interface SalesforceAccount { WH_Account__c?: string; } -interface SalesforceCreateResult { +interface _SalesforceCreateResult { id: string; success: boolean; } @@ -150,7 +150,7 @@ export class SalesforceAccountService { } else { const sobject = this.connection.sobject("Account"); const result = await sobject.create(sfData); - return { id: result.id || '', created: true }; + return { id: result.id || "", created: true }; } } catch (error) { this.logger.error("Failed to upsert account", { diff --git a/apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts b/apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts index a783731b..9f2ec632 100644 --- a/apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts +++ b/apps/bff/src/vendors/salesforce/services/salesforce-connection.service.ts @@ -12,7 +12,7 @@ export interface SalesforceSObjectApi { update?: (data: Record & { Id: string }) => Promise; } -interface SalesforceRetryableSObjectApi extends SalesforceSObjectApi { +interface _SalesforceRetryableSObjectApi extends SalesforceSObjectApi { create: (data: Record) => Promise<{ id?: string }>; update?: (data: Record & { Id: string }) => Promise; } @@ -133,15 +133,15 @@ export class SalesforceConnection { async query(soql: string): Promise { try { return await this.connection.query(soql); - } catch (error: any) { + } catch (error: unknown) { // Check if this is a session expiration error if (this.isSessionExpiredError(error)) { this.logger.warn("Salesforce session expired, attempting to re-authenticate"); - + try { // Re-authenticate await this.connect(); - + // Retry the query once this.logger.debug("Retrying query after re-authentication"); return await this.connection.query(soql); @@ -153,38 +153,43 @@ export class SalesforceConnection { throw retryError; } } - + // Re-throw other errors as-is throw error; } } - private isSessionExpiredError(error: any): boolean { + private isSessionExpiredError(error: unknown): boolean { // Check for various session expiration indicators const errorMessage = getErrorMessage(error).toLowerCase(); - const errorCode = error?.errorCode || error?.name || ''; - + const errorCode = + (error as { errorCode?: string; name?: string })?.errorCode || + (error as { errorCode?: string; name?: string })?.name || + ""; + return ( - errorCode === 'INVALID_SESSION_ID' || - errorMessage.includes('session expired') || - errorMessage.includes('invalid session') || - errorMessage.includes('invalid_session_id') || - (error?.status === 401 && errorMessage.includes('unauthorized')) + errorCode === "INVALID_SESSION_ID" || + errorMessage.includes("session expired") || + errorMessage.includes("invalid session") || + errorMessage.includes("invalid_session_id") || + ((error as { status?: number })?.status === 401 && errorMessage.includes("unauthorized")) ); } sobject(type: string): SalesforceSObjectApi { const originalSObject = this.connection.sobject(type); - + // Return a wrapper that handles session expiration for SObject operations return { create: async (data: Record) => { try { return await originalSObject.create(data); - } catch (error: any) { + } catch (error: unknown) { if (this.isSessionExpiredError(error)) { - this.logger.warn("Salesforce session expired during SObject create, attempting to re-authenticate"); - + this.logger.warn( + "Salesforce session expired during SObject create, attempting to re-authenticate" + ); + try { await this.connect(); const newSObject = this.connection.sobject(type); @@ -200,18 +205,20 @@ export class SalesforceConnection { throw error; } }, - + update: async (data: Record & { Id: string }) => { try { - return await originalSObject.update(data as any); - } catch (error: any) { + return await originalSObject.update(data); + } catch (error: unknown) { if (this.isSessionExpiredError(error)) { - this.logger.warn("Salesforce session expired during SObject update, attempting to re-authenticate"); - + this.logger.warn( + "Salesforce session expired during SObject update, attempting to re-authenticate" + ); + try { await this.connect(); const newSObject = this.connection.sobject(type); - return await newSObject.update(data as any); + return await newSObject.update(data); } catch (retryError) { this.logger.error("Failed to re-authenticate or retry SObject update", { originalError: getErrorMessage(error), @@ -222,7 +229,7 @@ export class SalesforceConnection { } throw error; } - } + }, }; } diff --git a/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts b/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts index 76f60549..dd395f49 100644 --- a/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts +++ b/apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts @@ -387,4 +387,20 @@ export class WhmcsConnectionService { async getPaymentGateways(): Promise { return this.makeRequest("GetPaymentMethods"); } + + // ========================================== + // ORDER METHODS (For Order Service) + // ========================================== + + async addOrder(params: Record): Promise { + return this.makeRequest("AddOrder", params); + } + + async acceptOrder(params: Record): Promise { + return this.makeRequest("AcceptOrder", params); + } + + async getOrders(params: Record): Promise { + return this.makeRequest("GetOrders", params); + } } diff --git a/apps/bff/src/vendors/whmcs/services/whmcs-order.service.ts b/apps/bff/src/vendors/whmcs/services/whmcs-order.service.ts new file mode 100644 index 00000000..d1aa1cf6 --- /dev/null +++ b/apps/bff/src/vendors/whmcs/services/whmcs-order.service.ts @@ -0,0 +1,306 @@ +import { Injectable, Inject } from "@nestjs/common"; +import { Logger } from "nestjs-pino"; +import { WhmcsConnectionService } from "./whmcs-connection.service"; +import { getErrorMessage } from "../../../common/utils/error.util"; + +export interface WhmcsOrderItem { + productId: string; // WHMCS Product ID from Product2.WHMCS_Product_Id__c + billingCycle: string; // monthly, quarterly, annually, onetime + quantity: number; + configOptions?: Record; + customFields?: Record; +} + +export interface WhmcsAddOrderParams { + clientId: number; + items: WhmcsOrderItem[]; + paymentMethod: string; // Required by WHMCS API - e.g., "mailin", "paypal" + promoCode?: string; + notes?: string; + sfOrderId?: string; // For tracking back to Salesforce + noinvoice?: boolean; // Default false - create invoice + noemail?: boolean; // Default false - send emails +} + +export interface WhmcsOrderResult { + orderId: number; + invoiceId?: number; + serviceIds: number[]; +} + +@Injectable() +export class WhmcsOrderService { + constructor( + private readonly connection: WhmcsConnectionService, + @Inject(Logger) private readonly logger: Logger + ) {} + + /** + * Create order in WHMCS using AddOrder API + * Maps Salesforce OrderItems to WHMCS products + */ + async addOrder(params: WhmcsAddOrderParams): Promise<{ orderId: number }> { + this.logger.log("Creating WHMCS order", { + clientId: params.clientId, + itemCount: params.items.length, + sfOrderId: params.sfOrderId, + hasPromoCode: Boolean(params.promoCode), + }); + + try { + // Build WHMCS AddOrder payload + const addOrderPayload = this.buildAddOrderPayload(params); + + // Call WHMCS AddOrder API + const response = (await this.connection.addOrder(addOrderPayload)) as Record; + + if (response.result !== "success") { + throw new Error( + `WHMCS AddOrder failed: ${(response.message as string) || "Unknown error"}` + ); + } + + const orderId = parseInt(response.orderid as string, 10); + if (!orderId) { + throw new Error("WHMCS AddOrder did not return valid order ID"); + } + + this.logger.log("WHMCS order created successfully", { + orderId, + clientId: params.clientId, + sfOrderId: params.sfOrderId, + }); + + return { orderId }; + } catch (error) { + this.logger.error("Failed to create WHMCS order", { + error: getErrorMessage(error), + clientId: params.clientId, + sfOrderId: params.sfOrderId, + }); + throw error; + } + } + + /** + * Accept/provision order in WHMCS using AcceptOrder API + * This activates services and creates subscriptions + */ + async acceptOrder(orderId: number, sfOrderId?: string): Promise { + this.logger.log("Accepting WHMCS order", { + orderId, + sfOrderId, + }); + + try { + // Call WHMCS AcceptOrder API + const response = (await this.connection.acceptOrder({ + orderid: orderId.toString(), + })) as Record; + + if (response.result !== "success") { + throw new Error( + `WHMCS AcceptOrder failed: ${(response.message as string) || "Unknown error"}` + ); + } + + // Extract service IDs from response + const serviceIds: number[] = []; + if (response.serviceids) { + // serviceids can be a string of comma-separated IDs + const ids = (response.serviceids as string).toString().split(","); + serviceIds.push(...ids.map((id: string) => parseInt(id.trim(), 10)).filter(Boolean)); + } + + const result: WhmcsOrderResult = { + orderId, + invoiceId: response.invoiceid ? parseInt(response.invoiceid as string, 10) : undefined, + serviceIds, + }; + + this.logger.log("WHMCS order accepted successfully", { + orderId, + invoiceId: result.invoiceId, + serviceCount: serviceIds.length, + sfOrderId, + }); + + return result; + } catch (error) { + this.logger.error("Failed to accept WHMCS order", { + error: getErrorMessage(error), + orderId, + sfOrderId, + }); + throw error; + } + } + + /** + * Get order details from WHMCS + */ + async getOrderDetails(orderId: number): Promise | null> { + try { + const response = (await this.connection.getOrders({ + id: orderId.toString(), + })) as Record; + + if (response.result !== "success") { + throw new Error( + `WHMCS GetOrders failed: ${(response.message as string) || "Unknown error"}` + ); + } + + return (response.orders as { order?: Record[] })?.order?.[0] || null; + } catch (error) { + this.logger.error("Failed to get WHMCS order details", { + error: getErrorMessage(error), + orderId, + }); + throw error; + } + } + + /** + * Check if client has valid payment method + */ + async hasPaymentMethod(clientId: number): Promise { + try { + const response = (await this.connection.getPayMethods({ + clientid: clientId, + })) as unknown as Record; + + if (response.result !== "success") { + this.logger.warn("Failed to check payment methods", { + clientId, + error: response.message as string, + }); + return false; + } + + // Check if client has any payment methods + const paymentMethods = (response.paymethods as { paymethod?: unknown[] })?.paymethod || []; + const hasValidMethod = Array.isArray(paymentMethods) + ? paymentMethods.length > 0 + : Boolean(paymentMethods); + + this.logger.log("Payment method check completed", { + clientId, + hasPaymentMethod: hasValidMethod, + methodCount: Array.isArray(paymentMethods) ? paymentMethods.length : hasValidMethod ? 1 : 0, + }); + + return hasValidMethod; + } catch (error) { + this.logger.error("Failed to check payment methods", { + error: getErrorMessage(error), + clientId, + }); + // Don't throw - return false to indicate no payment method + return false; + } + } + + /** + * Build WHMCS AddOrder payload from our parameters + * Following official WHMCS API documentation format + */ + private buildAddOrderPayload(params: WhmcsAddOrderParams): Record { + const payload: Record = { + clientid: params.clientId, + paymentmethod: params.paymentMethod, // Required by WHMCS API + noinvoice: params.noinvoice ? true : false, + noemail: params.noemail ? true : false, + }; + + // Add promo code if specified + if (params.promoCode) { + payload.promocode = params.promoCode; + } + + // Extract arrays for WHMCS API format + const pids: string[] = []; + const billingCycles: string[] = []; + const quantities: number[] = []; + const configOptions: string[] = []; + const customFields: string[] = []; + + params.items.forEach((item) => { + pids.push(item.productId); + billingCycles.push(item.billingCycle); + quantities.push(item.quantity); + + // Handle config options - WHMCS expects base64 encoded serialized arrays + if (item.configOptions && Object.keys(item.configOptions).length > 0) { + const serialized = this.serializeForWhmcs(item.configOptions); + configOptions.push(serialized); + } else { + configOptions.push(""); // Empty string for items without config options + } + + // Handle custom fields - WHMCS expects base64 encoded serialized arrays + if (item.customFields && Object.keys(item.customFields).length > 0) { + const serialized = this.serializeForWhmcs(item.customFields); + customFields.push(serialized); + } else { + customFields.push(""); // Empty string for items without custom fields + } + }); + + // Set arrays in WHMCS format + payload.pid = pids; + payload.billingcycle = billingCycles; + payload.qty = quantities; + + if (configOptions.some(opt => opt !== "")) { + payload.configoptions = configOptions; + } + + if (customFields.some(field => field !== "")) { + payload.customfields = customFields; + } + + this.logger.debug("Built WHMCS AddOrder payload", { + clientId: params.clientId, + productCount: params.items.length, + pids, + billingCycles, + hasConfigOptions: configOptions.some(opt => opt !== ""), + hasCustomFields: customFields.some(field => field !== ""), + }); + + return payload; + } + + /** + * Serialize data for WHMCS API (base64 encoded serialized array) + */ + private serializeForWhmcs(data: Record): string { + try { + // Convert to PHP-style serialized format, then base64 encode + const serialized = this.phpSerialize(data); + return Buffer.from(serialized).toString('base64'); + } catch (error) { + this.logger.warn("Failed to serialize data for WHMCS", { + error: getErrorMessage(error), + data, + }); + return ""; + } + } + + /** + * Simple PHP serialize implementation for WHMCS compatibility + * Handles string values only (sufficient for config options and custom fields) + */ + private phpSerialize(data: Record): string { + const entries = Object.entries(data); + const serializedEntries = entries.map(([key, value]) => { + // Ensure values are strings and escape quotes + const safeKey = String(key).replace(/"/g, '\\"'); + const safeValue = String(value).replace(/"/g, '\\"'); + return `s:${safeKey.length}:"${safeKey}";s:${safeValue.length}:"${safeValue}";`; + }); + return `a:${entries.length}:{${serializedEntries.join('')}}`; + } +} diff --git a/apps/bff/src/vendors/whmcs/whmcs.module.ts b/apps/bff/src/vendors/whmcs/whmcs.module.ts index 2ac03d1d..a1201458 100644 --- a/apps/bff/src/vendors/whmcs/whmcs.module.ts +++ b/apps/bff/src/vendors/whmcs/whmcs.module.ts @@ -9,6 +9,7 @@ import { WhmcsSubscriptionService } from "./services/whmcs-subscription.service" import { WhmcsClientService } from "./services/whmcs-client.service"; import { WhmcsPaymentService } from "./services/whmcs-payment.service"; import { WhmcsSsoService } from "./services/whmcs-sso.service"; +import { WhmcsOrderService } from "./services/whmcs-order.service"; @Module({ imports: [ConfigModule], @@ -21,6 +22,7 @@ import { WhmcsSsoService } from "./services/whmcs-sso.service"; WhmcsClientService, WhmcsPaymentService, WhmcsSsoService, + WhmcsOrderService, WhmcsService, ], exports: [WhmcsService, WhmcsConnectionService, WhmcsDataTransformer, WhmcsCacheService], diff --git a/apps/bff/src/vendors/whmcs/whmcs.service.ts b/apps/bff/src/vendors/whmcs/whmcs.service.ts index e6423411..c352959d 100644 --- a/apps/bff/src/vendors/whmcs/whmcs.service.ts +++ b/apps/bff/src/vendors/whmcs/whmcs.service.ts @@ -17,6 +17,7 @@ import { import { WhmcsClientService } from "./services/whmcs-client.service"; import { WhmcsPaymentService } from "./services/whmcs-payment.service"; import { WhmcsSsoService } from "./services/whmcs-sso.service"; +import { WhmcsOrderService } from "./services/whmcs-order.service"; import { WhmcsAddClientParams, WhmcsClientResponse, @@ -36,6 +37,7 @@ export class WhmcsService { private readonly clientService: WhmcsClientService, private readonly paymentService: WhmcsPaymentService, private readonly ssoService: WhmcsSsoService, + private readonly orderService: WhmcsOrderService, @Inject(Logger) private readonly logger: Logger ) {} @@ -306,4 +308,16 @@ export class WhmcsService { async getSystemInfo(): Promise { return this.connectionService.getSystemInfo(); } + + // ========================================== + // ORDER OPERATIONS (delegate to OrderService) + // ========================================== + + /** + * Get order service for direct access to order operations + * Used by OrderProvisioningService for complex order workflows + */ + getOrderService(): WhmcsOrderService { + return this.orderService; + } } diff --git a/apps/bff/src/webhooks/guards/enhanced-webhook-signature.guard.ts b/apps/bff/src/webhooks/guards/enhanced-webhook-signature.guard.ts new file mode 100644 index 00000000..8aa241f0 --- /dev/null +++ b/apps/bff/src/webhooks/guards/enhanced-webhook-signature.guard.ts @@ -0,0 +1,204 @@ +import { Injectable, CanActivate, ExecutionContext, UnauthorizedException } from "@nestjs/common"; +import { ConfigService } from "@nestjs/config"; +import { Request } from "express"; +import crypto from "node:crypto"; +import { Logger } from "nestjs-pino"; +import { Inject } from "@nestjs/common"; + +interface WebhookRequest extends Request { + webhookMetadata?: { + sourceIp: string; + timestamp: Date; + nonce: string; + signature: string; + }; +} + +@Injectable() +export class EnhancedWebhookSignatureGuard implements CanActivate { + private readonly nonceStore = new Set(); // In production, use Redis + private readonly maxNonceAge = 5 * 60 * 1000; // 5 minutes + private readonly allowedIps: string[]; + + constructor( + private configService: ConfigService, + @Inject(Logger) private readonly logger: Logger + ) { + // Parse IP allowlist from environment + const ipAllowlist = this.configService.get("SF_WEBHOOK_IP_ALLOWLIST"); + this.allowedIps = ipAllowlist ? ipAllowlist.split(",").map(ip => ip.trim()) : []; + } + + canActivate(context: ExecutionContext): boolean { + const request = context.switchToHttp().getRequest(); + + try { + // 1. Verify source IP if allowlist is configured + if (this.allowedIps.length > 0) { + this.verifySourceIp(request); + } + + // 2. Extract and verify required headers + const headers = this.extractHeaders(request); + + // 3. Verify timestamp (prevent replay attacks) + this.verifyTimestamp(headers.timestamp); + + // 4. Verify nonce (prevent duplicate processing) + this.verifyNonce(headers.nonce); + + // 5. Verify HMAC signature + this.verifyHmacSignature(request, headers.signature); + + // Store metadata for logging/monitoring + request.webhookMetadata = { + sourceIp: request.ip || "unknown", + timestamp: new Date(headers.timestamp), + nonce: headers.nonce, + signature: headers.signature, + }; + + this.logger.log("Webhook security validation passed", { + sourceIp: request.ip, + nonce: headers.nonce, + timestamp: headers.timestamp, + }); + + return true; + } catch (error) { + this.logger.warn("Webhook security validation failed", { + sourceIp: request.ip, + error: error instanceof Error ? error.message : String(error), + userAgent: request.headers["user-agent"], + }); + throw error; + } + } + + private verifySourceIp(request: Request): void { + const clientIp = request.ip || request.connection.remoteAddress || "unknown"; + + // Check if IP is in allowlist (simplified - in production use proper CIDR matching) + const isAllowed = this.allowedIps.some(allowedIp => { + if (allowedIp.includes("/")) { + // CIDR notation - implement proper CIDR matching + return this.isIpInCidr(clientIp, allowedIp); + } + return clientIp === allowedIp; + }); + + if (!isAllowed) { + throw new UnauthorizedException(`IP ${clientIp} not in allowlist`); + } + } + + private extractHeaders(request: Request) { + const signature = + (request.headers["x-sf-signature"] as string) || + (request.headers["x-whmcs-signature"] as string); + + const timestamp = request.headers["x-sf-timestamp"] as string; + const nonce = request.headers["x-sf-nonce"] as string; + + if (!signature) { + throw new UnauthorizedException("Webhook signature is required"); + } + if (!timestamp) { + throw new UnauthorizedException("Webhook timestamp is required"); + } + if (!nonce) { + throw new UnauthorizedException("Webhook nonce is required"); + } + + return { signature, timestamp, nonce }; + } + + private verifyTimestamp(timestamp: string): void { + const requestTime = new Date(timestamp).getTime(); + const now = Date.now(); + const tolerance = this.configService.get("WEBHOOK_TIMESTAMP_TOLERANCE") || 300000; // 5 minutes + + if (isNaN(requestTime)) { + throw new UnauthorizedException("Invalid timestamp format"); + } + + if (Math.abs(now - requestTime) > tolerance) { + throw new UnauthorizedException("Request timestamp outside acceptable range"); + } + } + + private verifyNonce(nonce: string): void { + // Check if nonce was already used + if (this.nonceStore.has(nonce)) { + throw new UnauthorizedException("Nonce already used (replay attack detected)"); + } + + // Add nonce to store + this.nonceStore.add(nonce); + + // Clean up old nonces (in production, implement proper TTL with Redis) + this.cleanupOldNonces(); + } + + private verifyHmacSignature(request: Request, signature: string): void { + // Determine webhook type and get appropriate secret + const isWhmcs = Boolean(request.headers["x-whmcs-signature"]); + const isSalesforce = Boolean(request.headers["x-sf-signature"]); + + let secret: string | undefined; + if (isWhmcs) { + secret = this.configService.get("WHMCS_WEBHOOK_SECRET"); + } else if (isSalesforce) { + secret = this.configService.get("SF_WEBHOOK_SECRET"); + } + + if (!secret) { + throw new UnauthorizedException("Webhook secret not configured"); + } + + // Create signature from request body + const payload = Buffer.from(JSON.stringify(request.body), "utf8"); + const key = Buffer.from(secret, "utf8"); + const expectedSignature = crypto.createHmac("sha256", key).update(payload).digest("hex"); + + // Use constant-time comparison to prevent timing attacks + if (!this.constantTimeCompare(signature, expectedSignature)) { + throw new UnauthorizedException("Invalid webhook signature"); + } + } + + private constantTimeCompare(a: string, b: string): boolean { + if (a.length !== b.length) { + return false; + } + + let result = 0; + for (let i = 0; i < a.length; i++) { + result |= a.charCodeAt(i) ^ b.charCodeAt(i); + } + return result === 0; + } + + private isIpInCidr(ip: string, cidr: string): boolean { + // Simplified CIDR check - in production use a proper library like 'ip-range-check' + // This is a basic implementation for IPv4 + const [network, prefixLength] = cidr.split("/"); + const networkInt = this.ipToInt(network); + const ipInt = this.ipToInt(ip); + const mask = -1 << (32 - parseInt(prefixLength, 10)); + + return (networkInt & mask) === (ipInt & mask); + } + + private ipToInt(ip: string): number { + return ip.split(".").reduce((acc, octet) => (acc << 8) + parseInt(octet, 10), 0) >>> 0; + } + + private cleanupOldNonces(): void { + // In production, implement proper cleanup with Redis TTL + // This is a simplified cleanup for in-memory storage + if (this.nonceStore.size > 10000) { + this.nonceStore.clear(); + } + } +} diff --git a/apps/portal/src/app/account/profile/page.tsx b/apps/portal/src/app/account/profile/page.tsx index efece5f9..b170f607 100644 --- a/apps/portal/src/app/account/profile/page.tsx +++ b/apps/portal/src/app/account/profile/page.tsx @@ -254,10 +254,7 @@ export default function ProfilePage() { return; } - await useAuthStore.getState().changePassword( - pwdForm.currentPassword, - pwdForm.newPassword - ); + await useAuthStore.getState().changePassword(pwdForm.currentPassword, pwdForm.newPassword); setPwdSuccess("Password changed successfully."); setPwdForm({ currentPassword: "", newPassword: "", confirmPassword: "" }); } catch (err) { @@ -757,7 +754,8 @@ export default function ProfilePage() {

- Password must be at least 8 characters and include uppercase, lowercase, number, and special character. + Password must be at least 8 characters and include uppercase, lowercase, number, + and special character.

diff --git a/apps/portal/src/app/auth/signup/page.tsx b/apps/portal/src/app/auth/signup/page.tsx index 73fd03d7..19a8fa9e 100644 --- a/apps/portal/src/app/auth/signup/page.tsx +++ b/apps/portal/src/app/auth/signup/page.tsx @@ -162,33 +162,39 @@ export default function SignupPage() { }; // Check email when user enters it (debounced) - const handleEmailCheck = useCallback(async (email: string) => { - if (!email || !email.includes("@")) { - setEmailCheckStatus(null); - return; - } + const handleEmailCheck = useCallback( + async (email: string) => { + if (!email || !email.includes("@")) { + setEmailCheckStatus(null); + return; + } - try { - const result = await checkPasswordNeeded(email); - setEmailCheckStatus({ - userExists: result.userExists, - needsPasswordSet: result.needsPasswordSet, - showActions: result.userExists, - }); - } catch (err) { - // Silently fail email check - don't block the flow - setEmailCheckStatus(null); - } - }, [checkPasswordNeeded]); + try { + const result = await checkPasswordNeeded(email); + setEmailCheckStatus({ + userExists: result.userExists, + needsPasswordSet: result.needsPasswordSet, + showActions: result.userExists, + }); + } catch { + // Silently fail email check - don't block the flow + setEmailCheckStatus(null); + } + }, + [checkPasswordNeeded] + ); - const debouncedEmailCheck = useCallback((email: string) => { - if (emailCheckTimeoutRef.current) { - clearTimeout(emailCheckTimeoutRef.current); - } - emailCheckTimeoutRef.current = setTimeout(() => { - void handleEmailCheck(email); - }, 500); - }, [handleEmailCheck]); + const debouncedEmailCheck = useCallback( + (email: string) => { + if (emailCheckTimeoutRef.current) { + clearTimeout(emailCheckTimeoutRef.current); + } + emailCheckTimeoutRef.current = setTimeout(() => { + void handleEmailCheck(email); + }, 500); + }, + [handleEmailCheck] + ); // Step 2: Personal Information const onStep2Submit = () => { @@ -368,11 +374,11 @@ export default function SignupPage() { { + onChange: (e: React.ChangeEvent) => { const email = e.target.value; step2Form.setValue("email", email); debouncedEmailCheck(email); - } + }, })} id="email" type="email" @@ -383,14 +389,18 @@ export default function SignupPage() { {step2Form.formState.errors.email && (

{step2Form.formState.errors.email.message}

)} - + {/* Email Check Status */} {emailCheckStatus?.showActions && (
- +
@@ -403,8 +413,8 @@ export default function SignupPage() {

You need to set a password for your account.

- Set Password @@ -416,14 +426,14 @@ export default function SignupPage() { Please sign in to your existing account.

- Sign In - Forgot Password? diff --git a/apps/portal/src/app/billing/invoices/[id]/page.tsx b/apps/portal/src/app/billing/invoices/[id]/page.tsx index 0c66d7ec..4b1f7fee 100644 --- a/apps/portal/src/app/billing/invoices/[id]/page.tsx +++ b/apps/portal/src/app/billing/invoices/[id]/page.tsx @@ -20,7 +20,7 @@ import { format } from "date-fns"; import { formatCurrency } from "@/utils/currency"; import { useInvoice } from "@/features/billing/hooks"; import { createInvoiceSsoLink } from "@/features/billing/hooks"; -import { InvoiceStatusBadge, InvoiceItemRow } from "@/features/billing/components"; +import { InvoiceItemRow } from "@/features/billing/components"; export default function InvoiceDetailPage() { const params = useParams(); @@ -189,10 +189,10 @@ export default function InvoiceDetailPage() { invoice.status === "Paid" ? "success" : invoice.status === "Overdue" - ? "error" - : invoice.status === "Unpaid" - ? "warning" - : "neutral" + ? "error" + : invoice.status === "Unpaid" + ? "warning" + : "neutral" } />
diff --git a/apps/portal/src/app/billing/invoices/page.tsx b/apps/portal/src/app/billing/invoices/page.tsx index 8df2b21b..fedf98ce 100644 --- a/apps/portal/src/app/billing/invoices/page.tsx +++ b/apps/portal/src/app/billing/invoices/page.tsx @@ -106,7 +106,9 @@ export default function InvoicesPage() { { key: "status", header: "Status", - render: (invoice: Invoice) => , + render: (invoice: Invoice) => ( + + ), }, { key: "amount", @@ -233,7 +235,9 @@ export default function InvoicesPage() { Previous
@@ -307,7 +313,11 @@ function CheckoutContent() { icon={} >
- + {/* Confirm Details - single card with Address + Payment */}
@@ -335,7 +345,6 @@ function CheckoutContent() { ) : undefined } > - {paymentMethodsLoading ? (
@@ -346,13 +355,17 @@ function CheckoutContent() {
-

Unable to verify payment methods

-

If you just added a payment method, try refreshing.

+

+ Unable to verify payment methods +

+

+ If you just added a payment method, try refreshing. +

) : paymentMethods && paymentMethods.paymentMethods.length > 0 ? ( -

Payment will be processed using your card on file after approval.

+

+ Payment will be processed using your card on file after approval. +

) : (

No payment method on file

-

Add a payment method to submit your order.

+

+ Add a payment method to submit your order. +

-
diff --git a/apps/portal/src/components/checkout/address-confirmation.tsx b/apps/portal/src/components/checkout/address-confirmation.tsx index feff09e8..714b181a 100644 --- a/apps/portal/src/components/checkout/address-confirmation.tsx +++ b/apps/portal/src/components/checkout/address-confirmation.tsx @@ -84,7 +84,7 @@ export function AddressConfirmation({ const handleEdit = (e?: React.MouseEvent) => { e?.preventDefault(); e?.stopPropagation(); - + setEditing(true); setEditedAddress( billingInfo?.address || { @@ -101,7 +101,7 @@ export function AddressConfirmation({ const handleSave = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - + if (!editedAddress) return; // Validate required fields @@ -118,7 +118,7 @@ export function AddressConfirmation({ return; } - (async () => { + void (async () => { try { setError(null); @@ -168,7 +168,7 @@ export function AddressConfirmation({ const handleCancel = (e: React.MouseEvent) => { e.preventDefault(); e.stopPropagation(); - + setEditing(false); setEditedAddress(null); setError(null); @@ -178,11 +178,7 @@ export function AddressConfirmation({ // Note: Avoid defining wrapper components inside render to prevent remounts (focus loss) const wrap = (node: React.ReactNode) => - embedded ? ( - <>{node} - ) : ( -
{node}
- ); + embedded ? <>{node} :
{node}
; if (loading) { return wrap( @@ -369,7 +365,8 @@ export function AddressConfirmation({

{billingInfo.address.streetLine2}

)}

- {billingInfo.address.city}, {billingInfo.address.state} {billingInfo.address.postalCode} + {billingInfo.address.city}, {billingInfo.address.state}{" "} + {billingInfo.address.postalCode}

{billingInfo.address.country}

@@ -382,7 +379,8 @@ export function AddressConfirmation({

Verification Required

- Please confirm this is the correct installation address for your internet service. + Please confirm this is the correct installation address for your internet + service.

@@ -409,7 +407,7 @@ export function AddressConfirmation({ )}
- + {/* Edit button - always on the right */} {billingInfo.isComplete && !editing && (

No Address on File

-

Please add your installation address to continue.

+

+ Please add your installation address to continue. +