Update import paths for SubCard and FormField components across multiple files to improve module structure and maintainability.
This commit is contained in:
parent
be3af76e01
commit
5a8e9624ae
@ -0,0 +1,227 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import type {
|
||||
WhmcsInvoicesResponse,
|
||||
WhmcsInvoiceResponse,
|
||||
WhmcsProductsResponse,
|
||||
WhmcsClientResponse,
|
||||
WhmcsSsoResponse,
|
||||
WhmcsValidateLoginResponse,
|
||||
WhmcsAddClientResponse,
|
||||
WhmcsCatalogProductsResponse,
|
||||
WhmcsPayMethodsResponse,
|
||||
WhmcsAddPayMethodResponse,
|
||||
WhmcsPaymentGatewaysResponse,
|
||||
WhmcsCreateInvoiceResponse,
|
||||
WhmcsUpdateInvoiceResponse,
|
||||
WhmcsCapturePaymentResponse,
|
||||
WhmcsAddCreditResponse,
|
||||
WhmcsAddInvoicePaymentResponse,
|
||||
WhmcsGetInvoicesParams,
|
||||
WhmcsGetClientsProductsParams,
|
||||
WhmcsCreateSsoTokenParams,
|
||||
WhmcsValidateLoginParams,
|
||||
WhmcsAddClientParams,
|
||||
WhmcsGetPayMethodsParams,
|
||||
WhmcsAddPayMethodParams,
|
||||
WhmcsCreateInvoiceParams,
|
||||
WhmcsUpdateInvoiceParams,
|
||||
WhmcsCapturePaymentParams,
|
||||
WhmcsAddCreditParams,
|
||||
WhmcsAddInvoicePaymentParams,
|
||||
} from "../../types/whmcs-api.types";
|
||||
import { WhmcsHttpClientService } from "./whmcs-http-client.service";
|
||||
import { WhmcsConfigService } from "../config/whmcs-config.service";
|
||||
import type { WhmcsRequestOptions } from "../types/connection.types";
|
||||
|
||||
/**
|
||||
* Service containing all WHMCS API method implementations
|
||||
* Organized by functional area (clients, invoices, payments, etc.)
|
||||
*/
|
||||
@Injectable()
|
||||
export class WhmcsApiMethodsService {
|
||||
constructor(
|
||||
private readonly httpClient: WhmcsHttpClientService,
|
||||
private readonly configService: WhmcsConfigService
|
||||
) {}
|
||||
|
||||
// ==========================================
|
||||
// HEALTH CHECK METHODS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Perform health check on WHMCS API
|
||||
*/
|
||||
async healthCheck(): Promise<boolean> {
|
||||
try {
|
||||
await this.makeRequest("GetProducts", { limitnum: 1 });
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if WHMCS service is available
|
||||
*/
|
||||
async isAvailable(): Promise<boolean> {
|
||||
return this.healthCheck();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get WHMCS system information
|
||||
*/
|
||||
async getSystemInfo(): Promise<unknown> {
|
||||
return this.makeRequest("GetProducts", { limitnum: 1 });
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// CLIENT API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getClientDetails(clientId: number): Promise<WhmcsClientResponse> {
|
||||
return this.makeRequest<WhmcsClientResponse>("GetClientsDetails", {
|
||||
clientid: clientId,
|
||||
stats: true, // Required by some WHMCS versions to include defaultpaymethodid
|
||||
});
|
||||
}
|
||||
|
||||
async getClientDetailsByEmail(email: string): Promise<WhmcsClientResponse> {
|
||||
return this.makeRequest<WhmcsClientResponse>("GetClientsDetails", {
|
||||
email,
|
||||
stats: true,
|
||||
});
|
||||
}
|
||||
|
||||
async updateClient(
|
||||
clientId: number,
|
||||
updateData: Partial<WhmcsClientResponse["client"]>
|
||||
): Promise<{ result: string }> {
|
||||
return this.makeRequest<{ result: string }>("UpdateClient", {
|
||||
clientid: clientId,
|
||||
...updateData,
|
||||
});
|
||||
}
|
||||
|
||||
async addClient(params: WhmcsAddClientParams): Promise<WhmcsAddClientResponse> {
|
||||
return this.makeRequest("AddClient", params);
|
||||
}
|
||||
|
||||
async validateLogin(params: WhmcsValidateLoginParams): Promise<WhmcsValidateLoginResponse> {
|
||||
return this.makeRequest("ValidateLogin", params);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// INVOICE API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getInvoices(params: WhmcsGetInvoicesParams = {}): Promise<WhmcsInvoicesResponse> {
|
||||
return this.makeRequest("GetInvoices", params);
|
||||
}
|
||||
|
||||
async getInvoice(invoiceId: number): Promise<WhmcsInvoiceResponse> {
|
||||
return this.makeRequest("GetInvoice", { invoiceid: invoiceId });
|
||||
}
|
||||
|
||||
async createInvoice(params: WhmcsCreateInvoiceParams): Promise<WhmcsCreateInvoiceResponse> {
|
||||
return this.makeRequest("CreateInvoice", params);
|
||||
}
|
||||
|
||||
async updateInvoice(params: WhmcsUpdateInvoiceParams): Promise<WhmcsUpdateInvoiceResponse> {
|
||||
return this.makeRequest("UpdateInvoice", params);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PRODUCT/SUBSCRIPTION API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getClientsProducts(params: WhmcsGetClientsProductsParams): Promise<WhmcsProductsResponse> {
|
||||
return this.makeRequest("GetClientsProducts", params);
|
||||
}
|
||||
|
||||
async getCatalogProducts(): Promise<WhmcsCatalogProductsResponse> {
|
||||
return this.makeRequest("GetProducts", {});
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PAYMENT API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getPaymentMethods(params: WhmcsGetPayMethodsParams): Promise<WhmcsPayMethodsResponse> {
|
||||
return this.makeRequest("GetPayMethods", params);
|
||||
}
|
||||
|
||||
async addPaymentMethod(params: WhmcsAddPayMethodParams): Promise<WhmcsAddPayMethodResponse> {
|
||||
return this.makeRequest("AddPayMethod", params);
|
||||
}
|
||||
|
||||
async getPaymentGateways(): Promise<WhmcsPaymentGatewaysResponse> {
|
||||
return this.makeRequest("GetPaymentMethods", {});
|
||||
}
|
||||
|
||||
async capturePayment(params: WhmcsCapturePaymentParams): Promise<WhmcsCapturePaymentResponse> {
|
||||
return this.makeRequest("CapturePayment", params);
|
||||
}
|
||||
|
||||
async addCredit(params: WhmcsAddCreditParams): Promise<WhmcsAddCreditResponse> {
|
||||
return this.makeRequest("AddCredit", params);
|
||||
}
|
||||
|
||||
async addInvoicePayment(
|
||||
params: WhmcsAddInvoicePaymentParams
|
||||
): Promise<WhmcsAddInvoicePaymentResponse> {
|
||||
return this.makeRequest("AddInvoicePayment", params);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SSO API METHODS
|
||||
// ==========================================
|
||||
|
||||
async createSsoToken(params: WhmcsCreateSsoTokenParams): Promise<WhmcsSsoResponse> {
|
||||
return this.makeRequest("CreateSsoToken", params);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ADMIN API METHODS (require admin auth)
|
||||
// ==========================================
|
||||
|
||||
async acceptOrder(orderId: number): Promise<{ result: string }> {
|
||||
if (!this.configService.hasAdminAuth()) {
|
||||
throw new Error("Admin authentication required for AcceptOrder");
|
||||
}
|
||||
|
||||
return this.makeRequest<{ result: string }>(
|
||||
"AcceptOrder",
|
||||
{ orderid: orderId },
|
||||
{ useAdminAuth: true }
|
||||
);
|
||||
}
|
||||
|
||||
async cancelOrder(orderId: number): Promise<{ result: string }> {
|
||||
if (!this.configService.hasAdminAuth()) {
|
||||
throw new Error("Admin authentication required for CancelOrder");
|
||||
}
|
||||
|
||||
return this.makeRequest<{ result: string }>(
|
||||
"CancelOrder",
|
||||
{ orderid: orderId },
|
||||
{ useAdminAuth: true }
|
||||
);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// PRIVATE HELPER METHODS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Make a request using the HTTP client
|
||||
*/
|
||||
private async makeRequest<T>(
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
options: WhmcsRequestOptions = {}
|
||||
): Promise<T> {
|
||||
const config = this.configService.getConfig();
|
||||
const response = await this.httpClient.makeRequest<T>(config, action, params, options);
|
||||
return response.data as T;
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,209 @@
|
||||
import { Injectable, NotFoundException, BadRequestException, UnauthorizedException } from "@nestjs/common";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import type { WhmcsErrorResponse } from "../../types/whmcs-api.types";
|
||||
|
||||
/**
|
||||
* Service for handling and normalizing WHMCS API errors
|
||||
* Maps WHMCS errors to appropriate NestJS exceptions
|
||||
*/
|
||||
@Injectable()
|
||||
export class WhmcsErrorHandlerService {
|
||||
/**
|
||||
* Handle WHMCS API error response
|
||||
*/
|
||||
handleApiError(
|
||||
errorResponse: WhmcsErrorResponse,
|
||||
action: string,
|
||||
params: Record<string, unknown>
|
||||
): never {
|
||||
const message = errorResponse.message;
|
||||
const errorCode = errorResponse.errorcode;
|
||||
|
||||
// Normalize common, expected error responses to domain exceptions
|
||||
if (this.isNotFoundError(action, message)) {
|
||||
throw this.createNotFoundException(action, message, params);
|
||||
}
|
||||
|
||||
if (this.isAuthenticationError(message, errorCode)) {
|
||||
throw new UnauthorizedException(`WHMCS Authentication Error: ${message}`);
|
||||
}
|
||||
|
||||
if (this.isValidationError(message, errorCode)) {
|
||||
throw new BadRequestException(`WHMCS Validation Error: ${message}`);
|
||||
}
|
||||
|
||||
// Generic WHMCS API error
|
||||
throw new Error(`WHMCS API Error [${action}]: ${message} (${errorCode || 'unknown'})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle general request errors (network, timeout, etc.)
|
||||
*/
|
||||
handleRequestError(error: unknown, action: string, params: Record<string, unknown>): never {
|
||||
const message = getErrorMessage(error);
|
||||
|
||||
if (this.isTimeoutError(error)) {
|
||||
throw new Error(`WHMCS API timeout [${action}]: Request timed out`);
|
||||
}
|
||||
|
||||
if (this.isNetworkError(error)) {
|
||||
throw new Error(`WHMCS API network error [${action}]: ${message}`);
|
||||
}
|
||||
|
||||
// Re-throw the original error if it's already a known exception type
|
||||
if (this.isKnownException(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
// Generic request error
|
||||
throw new Error(`WHMCS API request failed [${action}]: ${message}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error indicates a not found condition
|
||||
*/
|
||||
private isNotFoundError(action: string, message: string): boolean {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
// Client not found errors
|
||||
if (action === "GetClientsDetails" && lowerMessage.includes("client not found")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Invoice not found errors
|
||||
if ((action === "GetInvoice" || action === "UpdateInvoice") &&
|
||||
lowerMessage.includes("invoice not found")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Product not found errors
|
||||
if (action === "GetClientsProducts" && lowerMessage.includes("no products found")) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error indicates authentication failure
|
||||
*/
|
||||
private isAuthenticationError(message: string, errorCode?: string): boolean {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
return lowerMessage.includes("authentication") ||
|
||||
lowerMessage.includes("unauthorized") ||
|
||||
lowerMessage.includes("invalid credentials") ||
|
||||
lowerMessage.includes("access denied") ||
|
||||
errorCode === "AUTHENTICATION_FAILED";
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error indicates validation failure
|
||||
*/
|
||||
private isValidationError(message: string, errorCode?: string): boolean {
|
||||
const lowerMessage = message.toLowerCase();
|
||||
|
||||
return lowerMessage.includes("required") ||
|
||||
lowerMessage.includes("invalid") ||
|
||||
lowerMessage.includes("missing") ||
|
||||
lowerMessage.includes("validation") ||
|
||||
errorCode === "VALIDATION_ERROR";
|
||||
}
|
||||
|
||||
/**
|
||||
* Create appropriate NotFoundException based on action and params
|
||||
*/
|
||||
private createNotFoundException(
|
||||
action: string,
|
||||
message: string,
|
||||
params: Record<string, unknown>
|
||||
): NotFoundException {
|
||||
if (action === "GetClientsDetails") {
|
||||
const emailParam = params["email"];
|
||||
if (typeof emailParam === "string") {
|
||||
return new NotFoundException(`Client with email ${emailParam} not found`);
|
||||
}
|
||||
|
||||
const clientIdParam = params["clientid"];
|
||||
const identifier =
|
||||
typeof clientIdParam === "string" || typeof clientIdParam === "number"
|
||||
? clientIdParam
|
||||
: "unknown";
|
||||
|
||||
return new NotFoundException(`Client with ID ${identifier} not found`);
|
||||
}
|
||||
|
||||
if (action === "GetInvoice" || action === "UpdateInvoice") {
|
||||
const invoiceIdParam = params["invoiceid"];
|
||||
const identifier =
|
||||
typeof invoiceIdParam === "string" || typeof invoiceIdParam === "number"
|
||||
? invoiceIdParam
|
||||
: "unknown";
|
||||
|
||||
return new NotFoundException(`Invoice with ID ${identifier} not found`);
|
||||
}
|
||||
|
||||
// Generic not found error
|
||||
return new NotFoundException(message);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a timeout error
|
||||
*/
|
||||
private isTimeoutError(error: unknown): boolean {
|
||||
const message = getErrorMessage(error).toLowerCase();
|
||||
return message.includes("timeout") ||
|
||||
message.includes("aborted") ||
|
||||
(error instanceof Error && error.name === "AbortError");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is a network error
|
||||
*/
|
||||
private isNetworkError(error: unknown): boolean {
|
||||
const message = getErrorMessage(error).toLowerCase();
|
||||
return message.includes("network") ||
|
||||
message.includes("connection") ||
|
||||
message.includes("econnrefused") ||
|
||||
message.includes("enotfound") ||
|
||||
message.includes("fetch");
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error is already a known NestJS exception
|
||||
*/
|
||||
private isKnownException(error: unknown): boolean {
|
||||
return error instanceof NotFoundException ||
|
||||
error instanceof BadRequestException ||
|
||||
error instanceof UnauthorizedException;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get user-friendly error message for client consumption
|
||||
*/
|
||||
getUserFriendlyMessage(error: unknown): string {
|
||||
if (error instanceof NotFoundException) {
|
||||
return "The requested resource was not found.";
|
||||
}
|
||||
|
||||
if (error instanceof BadRequestException) {
|
||||
return "The request contains invalid data.";
|
||||
}
|
||||
|
||||
if (error instanceof UnauthorizedException) {
|
||||
return "Authentication failed. Please check your credentials.";
|
||||
}
|
||||
|
||||
const message = getErrorMessage(error).toLowerCase();
|
||||
|
||||
if (message.includes("timeout")) {
|
||||
return "The request timed out. Please try again.";
|
||||
}
|
||||
|
||||
if (message.includes("network") || message.includes("connection")) {
|
||||
return "Network error. Please check your connection and try again.";
|
||||
}
|
||||
|
||||
return "An unexpected error occurred. Please try again later.";
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,308 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import type {
|
||||
WhmcsApiResponse,
|
||||
WhmcsErrorResponse
|
||||
} from "../../types/whmcs-api.types";
|
||||
import type {
|
||||
WhmcsApiConfig,
|
||||
WhmcsRequestOptions,
|
||||
WhmcsConnectionStats
|
||||
} from "../types/connection.types";
|
||||
|
||||
/**
|
||||
* Service for handling HTTP requests to WHMCS API
|
||||
* Manages timeouts, retries, and response parsing
|
||||
*/
|
||||
@Injectable()
|
||||
export class WhmcsHttpClientService {
|
||||
private stats: WhmcsConnectionStats = {
|
||||
totalRequests: 0,
|
||||
successfulRequests: 0,
|
||||
failedRequests: 0,
|
||||
averageResponseTime: 0,
|
||||
};
|
||||
|
||||
constructor(@Inject(Logger) private readonly logger: Logger) {}
|
||||
|
||||
/**
|
||||
* Make HTTP request to WHMCS API
|
||||
*/
|
||||
async makeRequest<T>(
|
||||
config: WhmcsApiConfig,
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
options: WhmcsRequestOptions = {}
|
||||
): Promise<WhmcsApiResponse<T>> {
|
||||
const startTime = Date.now();
|
||||
this.stats.totalRequests++;
|
||||
this.stats.lastRequestTime = new Date();
|
||||
|
||||
try {
|
||||
const response = await this.executeRequest<T>(config, action, params, options);
|
||||
|
||||
const responseTime = Date.now() - startTime;
|
||||
this.updateSuccessStats(responseTime);
|
||||
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.stats.failedRequests++;
|
||||
this.stats.lastErrorTime = new Date();
|
||||
|
||||
this.logger.error(`WHMCS HTTP request failed [${action}]`, {
|
||||
error: getErrorMessage(error),
|
||||
action,
|
||||
params: this.sanitizeLogParams(params),
|
||||
responseTime: Date.now() - startTime,
|
||||
});
|
||||
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get connection statistics
|
||||
*/
|
||||
getStats(): WhmcsConnectionStats {
|
||||
return { ...this.stats };
|
||||
}
|
||||
|
||||
/**
|
||||
* Reset connection statistics
|
||||
*/
|
||||
resetStats(): void {
|
||||
this.stats = {
|
||||
totalRequests: 0,
|
||||
successfulRequests: 0,
|
||||
failedRequests: 0,
|
||||
averageResponseTime: 0,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute the actual HTTP request with retry logic
|
||||
*/
|
||||
private async executeRequest<T>(
|
||||
config: WhmcsApiConfig,
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
options: WhmcsRequestOptions
|
||||
): Promise<WhmcsApiResponse<T>> {
|
||||
const maxAttempts = options.retryAttempts ?? config.retryAttempts ?? 3;
|
||||
let lastError: Error;
|
||||
|
||||
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
|
||||
try {
|
||||
return await this.performSingleRequest<T>(config, action, params, options);
|
||||
} catch (error) {
|
||||
lastError = error as Error;
|
||||
|
||||
if (attempt === maxAttempts) {
|
||||
break;
|
||||
}
|
||||
|
||||
// Don't retry on certain error types
|
||||
if (this.shouldNotRetry(error)) {
|
||||
break;
|
||||
}
|
||||
|
||||
const delay = this.calculateRetryDelay(attempt, config.retryDelay ?? 1000);
|
||||
this.logger.warn(`WHMCS request failed, retrying in ${delay}ms`, {
|
||||
action,
|
||||
attempt,
|
||||
maxAttempts,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
|
||||
await this.sleep(delay);
|
||||
}
|
||||
}
|
||||
|
||||
throw lastError!;
|
||||
}
|
||||
|
||||
/**
|
||||
* Perform a single HTTP request
|
||||
*/
|
||||
private async performSingleRequest<T>(
|
||||
config: WhmcsApiConfig,
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
options: WhmcsRequestOptions
|
||||
): Promise<WhmcsApiResponse<T>> {
|
||||
const timeout = options.timeout ?? config.timeout ?? 30000;
|
||||
const controller = new AbortController();
|
||||
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
||||
|
||||
try {
|
||||
const requestBody = this.buildRequestBody(config, action, params, options);
|
||||
const url = `${config.baseUrl}/includes/api.php`;
|
||||
|
||||
const response = await fetch(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/x-www-form-urlencoded",
|
||||
"User-Agent": "CustomerPortal/1.0",
|
||||
},
|
||||
body: requestBody,
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
clearTimeout(timeoutId);
|
||||
|
||||
const responseText = await response.text();
|
||||
|
||||
if (!response.ok) {
|
||||
const snippet = responseText?.slice(0, 300);
|
||||
throw new Error(
|
||||
`HTTP ${response.status}: ${response.statusText}${snippet ? ` | Body: ${snippet}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
return this.parseResponse<T>(responseText, action, params);
|
||||
} finally {
|
||||
clearTimeout(timeoutId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Build request body for WHMCS API
|
||||
*/
|
||||
private buildRequestBody(
|
||||
config: WhmcsApiConfig,
|
||||
action: string,
|
||||
params: Record<string, unknown>,
|
||||
options: WhmcsRequestOptions
|
||||
): string {
|
||||
const formData = new URLSearchParams();
|
||||
|
||||
// Add authentication
|
||||
if (options.useAdminAuth && config.adminUsername && config.adminPasswordHash) {
|
||||
formData.append("username", config.adminUsername);
|
||||
formData.append("password", config.adminPasswordHash);
|
||||
} else {
|
||||
formData.append("identifier", config.identifier);
|
||||
formData.append("secret", config.secret);
|
||||
}
|
||||
|
||||
// Add action and response format
|
||||
formData.append("action", action);
|
||||
formData.append("responsetype", "json");
|
||||
|
||||
// Add parameters
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
if (value !== undefined && value !== null) {
|
||||
formData.append(key, String(value));
|
||||
}
|
||||
}
|
||||
|
||||
return formData.toString();
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse WHMCS API response
|
||||
*/
|
||||
private parseResponse<T>(
|
||||
responseText: string,
|
||||
action: string,
|
||||
params: Record<string, unknown>
|
||||
): WhmcsApiResponse<T> {
|
||||
let data: WhmcsApiResponse<T>;
|
||||
|
||||
try {
|
||||
data = JSON.parse(responseText) as WhmcsApiResponse<T>;
|
||||
} catch (parseError) {
|
||||
this.logger.error(`Invalid JSON response from WHMCS API [${action}]`, {
|
||||
responseText: responseText.substring(0, 500),
|
||||
parseError: getErrorMessage(parseError),
|
||||
params: this.sanitizeLogParams(params),
|
||||
});
|
||||
throw new Error("Invalid JSON response from WHMCS API");
|
||||
}
|
||||
|
||||
if (data.result === "error") {
|
||||
const errorResponse = data as WhmcsErrorResponse;
|
||||
throw new Error(`WHMCS API Error: ${errorResponse.message} (${errorResponse.errorcode || 'unknown'})`);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if error should not be retried
|
||||
*/
|
||||
private shouldNotRetry(error: unknown): boolean {
|
||||
const message = getErrorMessage(error).toLowerCase();
|
||||
|
||||
// Don't retry authentication errors
|
||||
if (message.includes('authentication') || message.includes('unauthorized')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Don't retry validation errors
|
||||
if (message.includes('invalid') || message.includes('required')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Don't retry not found errors
|
||||
if (message.includes('not found')) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Calculate retry delay with exponential backoff
|
||||
*/
|
||||
private calculateRetryDelay(attempt: number, baseDelay: number): number {
|
||||
const exponentialDelay = baseDelay * Math.pow(2, attempt - 1);
|
||||
const jitter = Math.random() * 0.1 * exponentialDelay;
|
||||
return Math.min(exponentialDelay + jitter, 10000); // Max 10 seconds
|
||||
}
|
||||
|
||||
/**
|
||||
* Sleep for specified milliseconds
|
||||
*/
|
||||
private sleep(ms: number): Promise<void> {
|
||||
return new Promise(resolve => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
/**
|
||||
* Update success statistics
|
||||
*/
|
||||
private updateSuccessStats(responseTime: number): void {
|
||||
this.stats.successfulRequests++;
|
||||
|
||||
// Update average response time
|
||||
const totalSuccessful = this.stats.successfulRequests;
|
||||
this.stats.averageResponseTime =
|
||||
(this.stats.averageResponseTime * (totalSuccessful - 1) + responseTime) / totalSuccessful;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize parameters for logging (remove sensitive data)
|
||||
*/
|
||||
private sanitizeLogParams(params: Record<string, unknown>): Record<string, unknown> {
|
||||
const sensitiveKeys = [
|
||||
'password', 'secret', 'token', 'key', 'auth',
|
||||
'credit_card', 'cvv', 'ssn', 'social_security'
|
||||
];
|
||||
|
||||
const sanitized: Record<string, unknown> = {};
|
||||
|
||||
for (const [key, value] of Object.entries(params)) {
|
||||
const keyLower = key.toLowerCase();
|
||||
const isSensitive = sensitiveKeys.some(sensitive => keyLower.includes(sensitive));
|
||||
|
||||
if (isSensitive) {
|
||||
sanitized[key] = '[REDACTED]';
|
||||
} else {
|
||||
sanitized[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
return sanitized;
|
||||
}
|
||||
}
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SubCard } from "@/components/molecules/SubCard";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
|
||||
interface PasswordChangeCardProps {
|
||||
isChanging: boolean;
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { SubCard } from "@/components/molecules/SubCard";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { UserIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
|
||||
import type { ProfileEditFormData } from "@customer-portal/domain";
|
||||
|
||||
|
||||
@ -2,7 +2,7 @@
|
||||
|
||||
import { useCallback } from "react";
|
||||
import { Button, Input, ErrorMessage } from "@/components/atoms";
|
||||
import { FormField } from "@/components/molecules/FormField";
|
||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||
import { useWhmcsLink } from "@/features/auth/hooks";
|
||||
import {
|
||||
linkWhmcsRequestSchema,
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
import { useCallback } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button, Input, ErrorMessage } from "@/components/atoms";
|
||||
import { FormField } from "@/components/molecules/FormField";
|
||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||
import { useLogin } from "../../hooks/use-auth";
|
||||
import {
|
||||
loginFormSchema,
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button, Input, ErrorMessage } from "@/components/atoms";
|
||||
import { FormField } from "@/components/molecules/FormField";
|
||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||
import { usePasswordReset } from "../../hooks/use-auth";
|
||||
import { useZodForm } from "@customer-portal/validation";
|
||||
import {
|
||||
|
||||
@ -8,7 +8,7 @@
|
||||
import { useEffect } from "react";
|
||||
import Link from "next/link";
|
||||
import { Button, Input, ErrorMessage } from "@/components/atoms";
|
||||
import { FormField } from "@/components/molecules/FormField";
|
||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||
import { useWhmcsLink } from "../../hooks/use-auth";
|
||||
import { useZodForm } from "@customer-portal/validation";
|
||||
import {
|
||||
|
||||
@ -6,7 +6,7 @@
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/atoms";
|
||||
import { FormField } from "@/components/molecules/FormField";
|
||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||
|
||||
interface AccountStepProps {
|
||||
formData: {
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { SubCard } from "@/components/molecules/SubCard";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { formatCurrency } from "@customer-portal/domain";
|
||||
import type { InvoiceItem } from "@customer-portal/domain";
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import { SubCard } from "@/components/molecules/SubCard";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { formatCurrency } from "@customer-portal/domain";
|
||||
|
||||
interface InvoiceTotalsProps {
|
||||
|
||||
@ -1,10 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { useMemo, useState } from "react";
|
||||
import { SubCard } from "@/components/molecules/SubCard";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { LoadingTable } from "@/components/atoms/loading-skeleton";
|
||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock";
|
||||
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar";
|
||||
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
|
||||
import { PaginationBar } from "@/components/molecules/PaginationBar";
|
||||
import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable";
|
||||
import { useInvoices } from "@/features/billing/hooks/useBilling";
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { SubCard } from "@/components/molecules/SubCard";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
import { ErrorState } from "@/components/atoms/error-state";
|
||||
import { CheckCircleIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { ErrorBoundary } from "@/components/molecules";
|
||||
import { SubCard } from "@/components/molecules/SubCard";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { useSession } from "@/features/auth/hooks";
|
||||
import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||
|
||||
|
||||
@ -3,7 +3,7 @@
|
||||
import { Skeleton } from "@/components/atoms";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { SubCard } from "@/components/molecules/SubCard";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import { accountService } from "@/features/account/services/account.service";
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
import { useCheckout } from "@/features/checkout/hooks/useCheckout";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { SubCard } from "@/components/molecules/SubCard";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner";
|
||||
import { PageAsync } from "@/components/molecules/AsyncBlock";
|
||||
|
||||
@ -11,7 +11,7 @@ import {
|
||||
LockClosedIcon,
|
||||
CubeIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { SubCard } from "@/components/molecules/SubCard";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import { ordersService } from "@/features/orders/services/orders.service";
|
||||
import { calculateOrderTotals, deriveOrderStatusDescriptor, getServiceCategory } from "@/features/orders/utils/order-presenters";
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { SubCard } from "@/components/molecules/SubCard";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { formatCurrency, getCurrencyLocale } from "@customer-portal/domain";
|
||||
import type { Subscription } from "@customer-portal/domain";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
TagIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import { SubCard } from "@/components/molecules/SubCard";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { formatCurrency, getCurrencyLocale } from "@customer-portal/domain";
|
||||
import type { Subscription } from "@customer-portal/domain";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
@ -4,7 +4,7 @@ import { useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { SubCard } from "@/components/molecules/SubCard";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
|
||||
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner";
|
||||
|
||||
@ -4,7 +4,7 @@ import { useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams } from "next/navigation";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { SubCard } from "@/components/molecules/SubCard";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
|
||||
import { AlertBanner } from "@/components/molecules/AlertBanner";
|
||||
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
import { SubCard } from "@/components/molecules/SubCard";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { DetailHeader } from "@/components/molecules/DetailHeader";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useParams, useSearchParams } from "next/navigation";
|
||||
|
||||
@ -8,8 +8,8 @@ import { ErrorBoundary } from "@/components/molecules";
|
||||
import { PageLayout } from "@/components/templates/PageLayout";
|
||||
import { DataTable } from "@/components/molecules/DataTable/DataTable";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
import { SubCard } from "@/components/molecules/SubCard";
|
||||
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar";
|
||||
import { SubCard } from "@/components/molecules/SubCard/SubCard";
|
||||
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
|
||||
import { LoadingTable } from "@/components/atoms/loading-skeleton";
|
||||
import { ErrorState } from "@/components/atoms/error-state";
|
||||
import { AsyncBlock } from "@/components/molecules/AsyncBlock";
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user