diff --git a/apps/bff/src/integrations/common/errors/base-provider.error.ts b/apps/bff/src/integrations/common/errors/base-provider.error.ts new file mode 100644 index 00000000..26648ddb --- /dev/null +++ b/apps/bff/src/integrations/common/errors/base-provider.error.ts @@ -0,0 +1,24 @@ +import type { HttpStatus } from "@nestjs/common"; +import type { ErrorCodeType } from "@customer-portal/domain/common"; + +/** + * Base class for all provider-specific errors. + * + * Carries structured metadata for error classification without string matching. + * The UnifiedExceptionFilter detects subclasses and maps them to API responses. + */ +export abstract class BaseProviderError extends Error { + abstract readonly provider: "whmcs" | "salesforce" | "freebit"; + abstract readonly providerCode: string; + abstract readonly domainErrorCode: ErrorCodeType; + abstract readonly httpStatus: HttpStatus; + + constructor( + message: string, + public readonly providerMessage: string, + public override readonly cause?: unknown + ) { + super(message); + this.name = this.constructor.name; + } +} diff --git a/apps/bff/src/integrations/common/errors/freebit.errors.ts b/apps/bff/src/integrations/common/errors/freebit.errors.ts new file mode 100644 index 00000000..1cf9a176 --- /dev/null +++ b/apps/bff/src/integrations/common/errors/freebit.errors.ts @@ -0,0 +1,36 @@ +import { HttpStatus } from "@nestjs/common"; +import { ErrorCode, FreebitProviderError } from "@customer-portal/domain/common"; +import { BaseProviderError } from "./base-provider.error.js"; + +export class FreebitApiError extends BaseProviderError { + readonly provider = "freebit" as const; + readonly providerCode = FreebitProviderError.API_ERROR; + readonly domainErrorCode = ErrorCode.EXTERNAL_SERVICE_ERROR; + readonly httpStatus = HttpStatus.BAD_GATEWAY; + + constructor(message: string, providerMessage: string, cause?: unknown) { + super(message, providerMessage, cause); + } +} + +export class FreebitAccountNotFoundError extends BaseProviderError { + readonly provider = "freebit" as const; + readonly providerCode = FreebitProviderError.ACCOUNT_NOT_FOUND; + readonly domainErrorCode = ErrorCode.NOT_FOUND; + readonly httpStatus = HttpStatus.NOT_FOUND; + + constructor(providerMessage: string, cause?: unknown) { + super("Freebit account not found", providerMessage, cause); + } +} + +export class FreebitTimeoutError extends BaseProviderError { + readonly provider = "freebit" as const; + readonly providerCode = FreebitProviderError.TIMEOUT; + readonly domainErrorCode = ErrorCode.TIMEOUT; + readonly httpStatus = HttpStatus.GATEWAY_TIMEOUT; + + constructor(providerMessage: string, cause?: unknown) { + super("Freebit request timed out", providerMessage, cause); + } +} diff --git a/apps/bff/src/integrations/common/errors/index.ts b/apps/bff/src/integrations/common/errors/index.ts new file mode 100644 index 00000000..612352c8 --- /dev/null +++ b/apps/bff/src/integrations/common/errors/index.ts @@ -0,0 +1,4 @@ +export { BaseProviderError } from "./base-provider.error.js"; +export * from "./whmcs.errors.js"; +export * from "./salesforce.errors.js"; +export * from "./freebit.errors.js"; diff --git a/apps/bff/src/integrations/common/errors/salesforce.errors.ts b/apps/bff/src/integrations/common/errors/salesforce.errors.ts new file mode 100644 index 00000000..3e9cb75a --- /dev/null +++ b/apps/bff/src/integrations/common/errors/salesforce.errors.ts @@ -0,0 +1,69 @@ +import { HttpStatus } from "@nestjs/common"; +import { ErrorCode, SalesforceProviderError } from "@customer-portal/domain/common"; +import { BaseProviderError } from "./base-provider.error.js"; + +export class SalesforceApiError extends BaseProviderError { + readonly provider = "salesforce" as const; + readonly providerCode = SalesforceProviderError.API_ERROR; + readonly domainErrorCode = ErrorCode.EXTERNAL_SERVICE_ERROR; + readonly httpStatus = HttpStatus.BAD_GATEWAY; + + constructor(message: string, providerMessage: string, cause?: unknown) { + super(message, providerMessage, cause); + } +} + +export class SalesforceSessionExpiredError extends BaseProviderError { + readonly provider = "salesforce" as const; + readonly providerCode = SalesforceProviderError.SESSION_EXPIRED; + readonly domainErrorCode = ErrorCode.EXTERNAL_SERVICE_ERROR; + readonly httpStatus = HttpStatus.SERVICE_UNAVAILABLE; + + constructor(providerMessage: string, cause?: unknown) { + super("Salesforce session expired", providerMessage, cause); + } +} + +export class SalesforceQueryError extends BaseProviderError { + readonly provider = "salesforce" as const; + readonly providerCode = SalesforceProviderError.QUERY_ERROR; + readonly domainErrorCode = ErrorCode.EXTERNAL_SERVICE_ERROR; + readonly httpStatus = HttpStatus.BAD_GATEWAY; + + constructor(providerMessage: string, cause?: unknown) { + super("Salesforce query failed", providerMessage, cause); + } +} + +export class SalesforceTimeoutError extends BaseProviderError { + readonly provider = "salesforce" as const; + readonly providerCode = SalesforceProviderError.TIMEOUT; + readonly domainErrorCode = ErrorCode.TIMEOUT; + readonly httpStatus = HttpStatus.GATEWAY_TIMEOUT; + + constructor(providerMessage: string, cause?: unknown) { + super("Salesforce request timed out", providerMessage, cause); + } +} + +export class SalesforceNetworkError extends BaseProviderError { + readonly provider = "salesforce" as const; + readonly providerCode = SalesforceProviderError.NETWORK_ERROR; + readonly domainErrorCode = ErrorCode.NETWORK_ERROR; + readonly httpStatus = HttpStatus.BAD_GATEWAY; + + constructor(providerMessage: string, cause?: unknown) { + super("Salesforce network error", providerMessage, cause); + } +} + +export class SalesforceRateLimitError extends BaseProviderError { + readonly provider = "salesforce" as const; + readonly providerCode = SalesforceProviderError.RATE_LIMITED; + readonly domainErrorCode = ErrorCode.RATE_LIMITED; + readonly httpStatus = HttpStatus.TOO_MANY_REQUESTS; + + constructor(providerMessage: string, cause?: unknown) { + super("Salesforce rate limit exceeded", providerMessage, cause); + } +} diff --git a/apps/bff/src/integrations/common/errors/whmcs.errors.ts b/apps/bff/src/integrations/common/errors/whmcs.errors.ts new file mode 100644 index 00000000..122b5c19 --- /dev/null +++ b/apps/bff/src/integrations/common/errors/whmcs.errors.ts @@ -0,0 +1,121 @@ +import { HttpStatus } from "@nestjs/common"; +import { ErrorCode, type ErrorCodeType } from "@customer-portal/domain/common"; +import { WhmcsProviderError } from "@customer-portal/domain/common"; +import { BaseProviderError } from "./base-provider.error.js"; + +export class WhmcsApiError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode = WhmcsProviderError.API_ERROR; + readonly domainErrorCode = ErrorCode.EXTERNAL_SERVICE_ERROR; + readonly httpStatus = HttpStatus.BAD_GATEWAY; + + constructor(message: string, providerMessage: string, cause?: unknown) { + super(message, providerMessage, cause); + } +} + +export class WhmcsNotFoundError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode: string; + readonly domainErrorCode = ErrorCode.NOT_FOUND; + readonly httpStatus = HttpStatus.NOT_FOUND; + + constructor( + resource: "client" | "invoice" | "product", + providerMessage: string, + cause?: unknown + ) { + super(`WHMCS ${resource} not found`, providerMessage, cause); + const codeMap = { + client: WhmcsProviderError.CLIENT_NOT_FOUND, + invoice: WhmcsProviderError.INVOICE_NOT_FOUND, + product: WhmcsProviderError.PRODUCT_NOT_FOUND, + } as const; + this.providerCode = codeMap[resource]; + } +} + +export class WhmcsAuthError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode = WhmcsProviderError.AUTH_FAILED; + readonly domainErrorCode = ErrorCode.EXTERNAL_SERVICE_ERROR; + readonly httpStatus = HttpStatus.SERVICE_UNAVAILABLE; + + constructor(providerMessage: string, cause?: unknown) { + super("WHMCS authentication failed", providerMessage, cause); + } +} + +export class WhmcsInvalidCredentialsError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode = WhmcsProviderError.INVALID_CREDENTIALS; + readonly domainErrorCode = ErrorCode.INVALID_CREDENTIALS; + readonly httpStatus = HttpStatus.UNAUTHORIZED; + + constructor(providerMessage: string, cause?: unknown) { + super("Invalid login credentials", providerMessage, cause); + } +} + +export class WhmcsValidationError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode = WhmcsProviderError.VALIDATION_ERROR; + readonly domainErrorCode = ErrorCode.VALIDATION_FAILED; + readonly httpStatus = HttpStatus.BAD_REQUEST; + + constructor(providerMessage: string, cause?: unknown) { + super("WHMCS validation failed", providerMessage, cause); + } +} + +export class WhmcsTimeoutError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode = WhmcsProviderError.TIMEOUT; + readonly domainErrorCode = ErrorCode.TIMEOUT; + readonly httpStatus = HttpStatus.GATEWAY_TIMEOUT; + + constructor(providerMessage: string, cause?: unknown) { + super("WHMCS request timed out", providerMessage, cause); + } +} + +export class WhmcsNetworkError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode = WhmcsProviderError.NETWORK_ERROR; + readonly domainErrorCode = ErrorCode.NETWORK_ERROR; + readonly httpStatus = HttpStatus.BAD_GATEWAY; + + constructor(providerMessage: string, cause?: unknown) { + super("WHMCS network error", providerMessage, cause); + } +} + +export class WhmcsRateLimitError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode = WhmcsProviderError.RATE_LIMITED; + readonly domainErrorCode = ErrorCode.RATE_LIMITED; + readonly httpStatus = HttpStatus.TOO_MANY_REQUESTS; + + constructor(providerMessage: string, cause?: unknown) { + super("WHMCS rate limit exceeded", providerMessage, cause); + } +} + +export class WhmcsHttpError extends BaseProviderError { + readonly provider = "whmcs" as const; + readonly providerCode = WhmcsProviderError.HTTP_ERROR; + readonly domainErrorCode: ErrorCodeType; + readonly httpStatus: HttpStatus; + + constructor( + upstreamStatus: number, + providerMessage: string, + domainErrorCode: ErrorCodeType, + httpStatus: HttpStatus, + cause?: unknown + ) { + super(`WHMCS HTTP ${upstreamStatus} error`, providerMessage, cause); + this.domainErrorCode = domainErrorCode; + this.httpStatus = httpStatus; + } +} diff --git a/packages/domain/common/provider-errors.ts b/packages/domain/common/provider-errors.ts new file mode 100644 index 00000000..cb4e009d --- /dev/null +++ b/packages/domain/common/provider-errors.ts @@ -0,0 +1,61 @@ +/** + * Provider-specific error codes for structured error detection. + * + * These codes identify known error conditions from external providers. + * Used by provider error classes in the BFF to replace brittle string matching. + */ + +export const WhmcsProviderError = { + // Authentication + AUTH_FAILED: "WHMCS_AUTH_FAILED", + INVALID_CREDENTIALS: "WHMCS_INVALID_CREDENTIALS", + + // Not found + CLIENT_NOT_FOUND: "WHMCS_CLIENT_NOT_FOUND", + INVOICE_NOT_FOUND: "WHMCS_INVOICE_NOT_FOUND", + PRODUCT_NOT_FOUND: "WHMCS_PRODUCT_NOT_FOUND", + + // Validation + VALIDATION_ERROR: "WHMCS_VALIDATION_ERROR", + + // Network/Infrastructure + TIMEOUT: "WHMCS_TIMEOUT", + NETWORK_ERROR: "WHMCS_NETWORK_ERROR", + RATE_LIMITED: "WHMCS_RATE_LIMITED", + HTTP_ERROR: "WHMCS_HTTP_ERROR", + + // Generic + API_ERROR: "WHMCS_API_ERROR", +} as const; + +export type WhmcsProviderErrorCode = (typeof WhmcsProviderError)[keyof typeof WhmcsProviderError]; + +export const SalesforceProviderError = { + // Authentication + SESSION_EXPIRED: "SF_SESSION_EXPIRED", + AUTH_FAILED: "SF_AUTH_FAILED", + + // Query + QUERY_ERROR: "SF_QUERY_ERROR", + RECORD_NOT_FOUND: "SF_RECORD_NOT_FOUND", + + // Network/Infrastructure + TIMEOUT: "SF_TIMEOUT", + NETWORK_ERROR: "SF_NETWORK_ERROR", + RATE_LIMITED: "SF_RATE_LIMITED", + + // Generic + API_ERROR: "SF_API_ERROR", +} as const; + +export type SalesforceProviderErrorCode = + (typeof SalesforceProviderError)[keyof typeof SalesforceProviderError]; + +export const FreebitProviderError = { + ACCOUNT_NOT_FOUND: "FREEBIT_ACCOUNT_NOT_FOUND", + TIMEOUT: "FREEBIT_TIMEOUT", + API_ERROR: "FREEBIT_API_ERROR", +} as const; + +export type FreebitProviderErrorCode = + (typeof FreebitProviderError)[keyof typeof FreebitProviderError];