/** * Unified Error Handling for Customer Portal * * Single source of truth for error codes and user-friendly messages. * Used by both BFF (for generating responses) and Portal (for parsing responses). */ import { z } from "zod"; // ============================================================================ // Error Categories and Severity // ============================================================================ export type ErrorCategory = | "authentication" | "authorization" | "validation" | "business" | "system" | "network"; export type ErrorSeverity = "low" | "medium" | "high" | "critical"; // ============================================================================ // Error Code Registry // ============================================================================ /** * All error codes used in the application. * Format: CATEGORY_NUMBER (e.g., AUTH_001, VAL_001) */ export const ErrorCode = { // Authentication Errors (AUTH_*) INVALID_CREDENTIALS: "AUTH_001", ACCOUNT_LOCKED: "AUTH_002", SESSION_EXPIRED: "AUTH_003", TOKEN_INVALID: "AUTH_004", TOKEN_REVOKED: "AUTH_005", REFRESH_TOKEN_INVALID: "AUTH_006", // Authorization Errors (AUTHZ_*) FORBIDDEN: "AUTHZ_001", ADMIN_REQUIRED: "AUTHZ_002", RESOURCE_ACCESS_DENIED: "AUTHZ_003", // Validation Errors (VAL_*) VALIDATION_FAILED: "VAL_001", REQUIRED_FIELD_MISSING: "VAL_002", INVALID_FORMAT: "VAL_003", NOT_FOUND: "VAL_004", // Business Logic Errors (BIZ_*) ACCOUNT_EXISTS: "BIZ_001", ACCOUNT_ALREADY_LINKED: "BIZ_002", CUSTOMER_NOT_FOUND: "BIZ_003", ORDER_ALREADY_PROCESSED: "BIZ_004", INSUFFICIENT_BALANCE: "BIZ_005", SERVICE_UNAVAILABLE: "BIZ_006", LEGACY_ACCOUNT_EXISTS: "BIZ_007", ACCOUNT_MAPPING_MISSING: "BIZ_008", // System Errors (SYS_*) INTERNAL_ERROR: "SYS_001", EXTERNAL_SERVICE_ERROR: "SYS_002", DATABASE_ERROR: "SYS_003", CONFIGURATION_ERROR: "SYS_004", // Network/Rate Limiting (NET_*) NETWORK_ERROR: "NET_001", TIMEOUT: "NET_002", RATE_LIMITED: "NET_003", // SIM Errors (SIM_*) SIM_ACTIVATION_FEE_REQUIRED: "SIM_001", SIM_NOT_FOUND: "SIM_002", SIM_PLAN_CHANGE_FAILED: "SIM_003", SIM_TOPUP_FAILED: "SIM_004", SIM_ACTIVATION_FAILED: "SIM_005", SIM_CANCELLATION_FAILED: "SIM_006", // Internet Errors (INT_*) INTERNET_ELIGIBILITY_NOT_REQUESTED: "INT_001", INTERNET_ELIGIBILITY_PENDING: "INT_002", INTERNET_INELIGIBLE: "INT_003", INTERNET_SERVICE_EXISTS: "INT_004", INTERNET_CHECK_FAILED: "INT_005", // Order Validation Errors (ORD_*) USER_MAPPING_NOT_FOUND: "ORD_001", WHMCS_CLIENT_NOT_LINKED: "ORD_002", NO_PAYMENT_METHOD: "ORD_003", INVALID_SKU: "ORD_004", RESIDENCE_CARD_NOT_SUBMITTED: "ORD_005", RESIDENCE_CARD_REJECTED: "ORD_006", ORDER_VALIDATION_FAILED: "ORD_007", FULFILLMENT_FAILED: "ORD_008", // Subscription Errors (SUB_*) SUBSCRIPTION_NOT_FOUND: "SUB_001", SUBSCRIPTION_CANCELLATION_FAILED: "SUB_002", SUBSCRIPTION_UPDATE_FAILED: "SUB_003", // Generic UNKNOWN: "GEN_001", } as const; export type ErrorCodeType = (typeof ErrorCode)[keyof typeof ErrorCode]; // ============================================================================ // Error Messages (User-Friendly) // ============================================================================ /** * User-friendly error messages for each error code. * These are safe to display to end users. */ export const ErrorMessages: Record = { // Authentication [ErrorCode.INVALID_CREDENTIALS]: "Invalid email or password. Please try again.", [ErrorCode.ACCOUNT_LOCKED]: "Your account has been temporarily locked due to multiple failed login attempts. Please try again later.", [ErrorCode.SESSION_EXPIRED]: "Your session has expired. Please log in again.", [ErrorCode.TOKEN_INVALID]: "Your session is invalid. Please log in again.", [ErrorCode.TOKEN_REVOKED]: "Your session has been revoked. Please log in again.", [ErrorCode.REFRESH_TOKEN_INVALID]: "Your session could not be refreshed. Please log in again.", // Authorization [ErrorCode.FORBIDDEN]: "You do not have permission to perform this action.", [ErrorCode.ADMIN_REQUIRED]: "Administrator access is required for this action.", [ErrorCode.RESOURCE_ACCESS_DENIED]: "You do not have access to this resource.", // Validation [ErrorCode.VALIDATION_FAILED]: "The provided data is invalid. Please check your input.", [ErrorCode.REQUIRED_FIELD_MISSING]: "Required information is missing.", [ErrorCode.INVALID_FORMAT]: "The data format is invalid.", [ErrorCode.NOT_FOUND]: "The requested resource was not found.", // Business Logic [ErrorCode.ACCOUNT_EXISTS]: "We couldn't create a new account with these details. Please sign in or contact support.", [ErrorCode.ACCOUNT_ALREADY_LINKED]: "This billing account is already linked to a portal account. Please sign in.", [ErrorCode.LEGACY_ACCOUNT_EXISTS]: "We couldn't create a new account with these details. Please transfer your account or contact support.", [ErrorCode.CUSTOMER_NOT_FOUND]: "Customer account not found. Please contact support.", [ErrorCode.ORDER_ALREADY_PROCESSED]: "This order has already been processed.", [ErrorCode.INSUFFICIENT_BALANCE]: "Insufficient account balance.", [ErrorCode.SERVICE_UNAVAILABLE]: "This service is temporarily unavailable. Please try again later.", [ErrorCode.ACCOUNT_MAPPING_MISSING]: "Your account isn’t fully set up yet. Please contact support or try again later.", // System [ErrorCode.INTERNAL_ERROR]: "An unexpected error occurred. Please try again later.", [ErrorCode.EXTERNAL_SERVICE_ERROR]: "An external service is temporarily unavailable. Please try again later.", [ErrorCode.DATABASE_ERROR]: "A system error occurred. Please try again later.", [ErrorCode.CONFIGURATION_ERROR]: "A system configuration error occurred. Please contact support.", // Network [ErrorCode.NETWORK_ERROR]: "Unable to connect to the server. Please check your internet connection.", [ErrorCode.TIMEOUT]: "The request timed out. Please try again.", [ErrorCode.RATE_LIMITED]: "Too many requests. Please wait a moment and try again.", // SIM [ErrorCode.SIM_ACTIVATION_FEE_REQUIRED]: "SIM orders require an activation fee.", [ErrorCode.SIM_NOT_FOUND]: "SIM subscription not found.", [ErrorCode.SIM_PLAN_CHANGE_FAILED]: "Failed to change SIM plan. Please try again.", [ErrorCode.SIM_TOPUP_FAILED]: "Failed to top up SIM data. Please try again.", [ErrorCode.SIM_ACTIVATION_FAILED]: "SIM activation failed. Please contact support.", [ErrorCode.SIM_CANCELLATION_FAILED]: "SIM cancellation failed. Please try again.", // Internet [ErrorCode.INTERNET_ELIGIBILITY_NOT_REQUESTED]: "Internet eligibility review is required before ordering.", [ErrorCode.INTERNET_ELIGIBILITY_PENDING]: "Internet eligibility review is still in progress. Please wait for review to complete.", [ErrorCode.INTERNET_INELIGIBLE]: "Internet service is not available for your address. Please contact support.", [ErrorCode.INTERNET_SERVICE_EXISTS]: "An active Internet service already exists for this account.", [ErrorCode.INTERNET_CHECK_FAILED]: "Unable to verify Internet eligibility. Please try again.", // Order Validation [ErrorCode.USER_MAPPING_NOT_FOUND]: "User account mapping is required before ordering.", [ErrorCode.WHMCS_CLIENT_NOT_LINKED]: "Billing system integration is required before ordering.", [ErrorCode.NO_PAYMENT_METHOD]: "A payment method is required before ordering.", [ErrorCode.INVALID_SKU]: "One or more products in your order are invalid.", [ErrorCode.RESIDENCE_CARD_NOT_SUBMITTED]: "Residence card submission is required for SIM orders.", [ErrorCode.RESIDENCE_CARD_REJECTED]: "Your residence card was rejected. Please resubmit and try again.", [ErrorCode.ORDER_VALIDATION_FAILED]: "Order validation failed. Please check your order details.", [ErrorCode.FULFILLMENT_FAILED]: "Order fulfillment failed. Please contact support.", // Subscription [ErrorCode.SUBSCRIPTION_NOT_FOUND]: "Subscription not found.", [ErrorCode.SUBSCRIPTION_CANCELLATION_FAILED]: "Subscription cancellation failed. Please try again.", [ErrorCode.SUBSCRIPTION_UPDATE_FAILED]: "Subscription update failed. Please try again.", // Generic [ErrorCode.UNKNOWN]: "An unexpected error occurred. Please try again.", }; // ============================================================================ // Error Metadata // ============================================================================ interface ErrorMetadata { category: ErrorCategory; severity: ErrorSeverity; shouldLogout: boolean; shouldRetry: boolean; logLevel: "error" | "warn" | "info" | "debug"; } /** * Metadata for each error code defining behavior and classification. */ export const ErrorMetadata: Record = { // Authentication - mostly medium severity, some trigger logout [ErrorCode.INVALID_CREDENTIALS]: { category: "authentication", severity: "medium", shouldLogout: false, shouldRetry: false, logLevel: "warn", }, [ErrorCode.ACCOUNT_LOCKED]: { category: "authentication", severity: "medium", shouldLogout: false, shouldRetry: false, logLevel: "warn", }, [ErrorCode.SESSION_EXPIRED]: { category: "authentication", severity: "low", shouldLogout: true, shouldRetry: false, // Session expiry is an expected flow (browser tabs, refresh loops, etc.) and can be extremely noisy. // Keep it available for debugging but avoid spamming production logs at info level. logLevel: "debug", }, [ErrorCode.TOKEN_INVALID]: { category: "authentication", severity: "medium", shouldLogout: true, shouldRetry: false, logLevel: "warn", }, [ErrorCode.TOKEN_REVOKED]: { category: "authentication", severity: "medium", shouldLogout: true, shouldRetry: false, logLevel: "warn", }, [ErrorCode.REFRESH_TOKEN_INVALID]: { category: "authentication", severity: "medium", shouldLogout: true, shouldRetry: false, logLevel: "warn", }, // Authorization [ErrorCode.FORBIDDEN]: { category: "authorization", severity: "medium", shouldLogout: false, shouldRetry: false, logLevel: "warn", }, [ErrorCode.ADMIN_REQUIRED]: { category: "authorization", severity: "medium", shouldLogout: false, shouldRetry: false, logLevel: "warn", }, [ErrorCode.RESOURCE_ACCESS_DENIED]: { category: "authorization", severity: "medium", shouldLogout: false, shouldRetry: false, logLevel: "warn", }, // Validation - low severity, informational [ErrorCode.VALIDATION_FAILED]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "info", }, [ErrorCode.REQUIRED_FIELD_MISSING]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "info", }, [ErrorCode.INVALID_FORMAT]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "info", }, [ErrorCode.NOT_FOUND]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "info", }, // Business Logic [ErrorCode.ACCOUNT_EXISTS]: { category: "business", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "info", }, [ErrorCode.ACCOUNT_ALREADY_LINKED]: { category: "business", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "info", }, [ErrorCode.LEGACY_ACCOUNT_EXISTS]: { category: "business", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "info", }, [ErrorCode.CUSTOMER_NOT_FOUND]: { category: "business", severity: "medium", shouldLogout: false, shouldRetry: false, logLevel: "warn", }, [ErrorCode.ORDER_ALREADY_PROCESSED]: { category: "business", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "info", }, [ErrorCode.INSUFFICIENT_BALANCE]: { category: "business", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "info", }, [ErrorCode.SERVICE_UNAVAILABLE]: { category: "business", severity: "medium", shouldLogout: false, shouldRetry: true, logLevel: "warn", }, [ErrorCode.ACCOUNT_MAPPING_MISSING]: { category: "business", severity: "medium", shouldLogout: false, shouldRetry: false, logLevel: "warn", }, // System - high severity [ErrorCode.INTERNAL_ERROR]: { category: "system", severity: "high", shouldLogout: false, shouldRetry: true, logLevel: "error", }, [ErrorCode.EXTERNAL_SERVICE_ERROR]: { category: "system", severity: "medium", shouldLogout: false, shouldRetry: true, logLevel: "error", }, [ErrorCode.DATABASE_ERROR]: { category: "system", severity: "critical", shouldLogout: false, shouldRetry: true, logLevel: "error", }, [ErrorCode.CONFIGURATION_ERROR]: { category: "system", severity: "critical", shouldLogout: false, shouldRetry: false, logLevel: "error", }, // Network [ErrorCode.NETWORK_ERROR]: { category: "network", severity: "medium", shouldLogout: false, shouldRetry: true, logLevel: "warn", }, [ErrorCode.TIMEOUT]: { category: "network", severity: "medium", shouldLogout: false, shouldRetry: true, logLevel: "warn", }, [ErrorCode.RATE_LIMITED]: { category: "network", severity: "low", shouldLogout: false, shouldRetry: true, logLevel: "warn", }, // SIM Errors [ErrorCode.SIM_ACTIVATION_FEE_REQUIRED]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "info", }, [ErrorCode.SIM_NOT_FOUND]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "warn", }, [ErrorCode.SIM_PLAN_CHANGE_FAILED]: { category: "business", severity: "medium", shouldLogout: false, shouldRetry: true, logLevel: "error", }, [ErrorCode.SIM_TOPUP_FAILED]: { category: "business", severity: "medium", shouldLogout: false, shouldRetry: true, logLevel: "error", }, [ErrorCode.SIM_ACTIVATION_FAILED]: { category: "business", severity: "medium", shouldLogout: false, shouldRetry: true, logLevel: "error", }, [ErrorCode.SIM_CANCELLATION_FAILED]: { category: "business", severity: "medium", shouldLogout: false, shouldRetry: true, logLevel: "error", }, // Internet Errors [ErrorCode.INTERNET_ELIGIBILITY_NOT_REQUESTED]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "info", }, [ErrorCode.INTERNET_ELIGIBILITY_PENDING]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "info", }, [ErrorCode.INTERNET_INELIGIBLE]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "info", }, [ErrorCode.INTERNET_SERVICE_EXISTS]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "warn", }, [ErrorCode.INTERNET_CHECK_FAILED]: { category: "system", severity: "medium", shouldLogout: false, shouldRetry: true, logLevel: "error", }, // Order Validation Errors [ErrorCode.USER_MAPPING_NOT_FOUND]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "warn", }, [ErrorCode.WHMCS_CLIENT_NOT_LINKED]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "warn", }, [ErrorCode.NO_PAYMENT_METHOD]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "info", }, [ErrorCode.INVALID_SKU]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "warn", }, [ErrorCode.RESIDENCE_CARD_NOT_SUBMITTED]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "info", }, [ErrorCode.RESIDENCE_CARD_REJECTED]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "warn", }, [ErrorCode.ORDER_VALIDATION_FAILED]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "warn", }, [ErrorCode.FULFILLMENT_FAILED]: { category: "system", severity: "high", shouldLogout: false, shouldRetry: true, logLevel: "error", }, // Subscription Errors [ErrorCode.SUBSCRIPTION_NOT_FOUND]: { category: "validation", severity: "low", shouldLogout: false, shouldRetry: false, logLevel: "warn", }, [ErrorCode.SUBSCRIPTION_CANCELLATION_FAILED]: { category: "business", severity: "medium", shouldLogout: false, shouldRetry: true, logLevel: "error", }, [ErrorCode.SUBSCRIPTION_UPDATE_FAILED]: { category: "business", severity: "medium", shouldLogout: false, shouldRetry: true, logLevel: "error", }, // Generic [ErrorCode.UNKNOWN]: { category: "system", severity: "medium", shouldLogout: false, shouldRetry: true, logLevel: "error", }, }; // ============================================================================ // Helper Functions // ============================================================================ /** * Map HTTP status codes to domain error codes. * Shared between BFF (for responses) and Portal (for parsing). */ export function mapHttpStatusToErrorCode(status?: number): ErrorCodeType { if (!status) return ErrorCode.UNKNOWN; switch (status) { case 401: return ErrorCode.SESSION_EXPIRED; case 403: return ErrorCode.FORBIDDEN; case 404: return ErrorCode.NOT_FOUND; case 409: return ErrorCode.ACCOUNT_EXISTS; case 400: return ErrorCode.VALIDATION_FAILED; case 429: return ErrorCode.RATE_LIMITED; case 503: return ErrorCode.SERVICE_UNAVAILABLE; default: return status >= 500 ? ErrorCode.INTERNAL_ERROR : ErrorCode.UNKNOWN; } } /** * Get user-friendly message for an error code */ export function getMessageForErrorCode(code: string): string { return ErrorMessages[code as ErrorCodeType] ?? ErrorMessages[ErrorCode.UNKNOWN]; } /** * Get metadata for an error code */ export function getErrorMetadata(code: string): ErrorMetadata { return ErrorMetadata[code as ErrorCodeType] ?? ErrorMetadata[ErrorCode.UNKNOWN]; } /** * Check if error code should trigger logout */ export function shouldLogoutForCode(code: string): boolean { return getErrorMetadata(code).shouldLogout; } /** * Check if error can be retried */ export function canRetryError(code: string): boolean { return getErrorMetadata(code).shouldRetry; } // NOTE: We intentionally do NOT support matching error messages to codes. // Error codes must be explicit and stable (returned from the API or thrown by server code). // ============================================================================ // Zod Schema for Error Response // ============================================================================ /** * Schema for standard API error response */ export const apiErrorSchema = z.object({ success: z.literal(false), error: z.object({ code: z.string(), message: z.string(), // Intentionally z.unknown() values — error details vary by error type and may contain nested objects details: z.record(z.string(), z.unknown()).optional(), }), }); export type ApiError = z.infer; /** * Create a standard error response object */ export function createErrorResponse( code: ErrorCodeType, customMessage?: string, details?: Record ): ApiError { return { success: false, error: { code, message: customMessage ?? getMessageForErrorCode(code), details, }, }; }