702 lines
20 KiB
TypeScript
Raw Permalink Normal View History

/**
* 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<ErrorCodeType, string> = {
// 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 isnt 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<ErrorCodeType, ErrorMetadata> = {
// 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<typeof apiErrorSchema>;
/**
* Create a standard error response object
*/
export function createErrorResponse(
code: ErrorCodeType,
customMessage?: string,
details?: Record<string, unknown>
): ApiError {
return {
success: false,
error: {
code,
message: customMessage ?? getMessageForErrorCode(code),
details,
},
};
}