import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import { RetryableErrors, withRetry } from "@bff/core/utils/retry.util.js"; import { redactForLogs } from "@bff/core/logging/redaction.util.js"; import { FreebitAuthService } from "./freebit-auth.service.js"; import { FreebitError } from "./freebit-error.service.js"; import { FreebitTestTrackerService } from "./freebit-test-tracker.service.js"; interface FreebitResponseBase { resultCode?: string | number; status?: { message?: string; statusCode?: string | number; }; } @Injectable() export class FreebitClientService { constructor( private readonly authService: FreebitAuthService, private readonly testTracker: FreebitTestTrackerService, @Inject(Logger) private readonly logger: Logger ) {} /** * Make an authenticated request to Freebit API with retry logic */ async makeAuthenticatedRequest( endpoint: string, payload: TPayload ): Promise { const config = this.authService.getConfig(); const authKey = await this.authService.getAuthKey(); const url = this.buildUrl(config.baseUrl, endpoint); const requestPayload = { ...payload, authKey }; let attempt = 0; try { const responseData = await withRetry( async () => { attempt += 1; this.logger.debug(`Freebit API request`, { url, attempt, maxAttempts: config.retryAttempts, payload: redactForLogs(requestPayload), }); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), config.timeout); try { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/x-www-form-urlencoded" }, body: `json=${JSON.stringify(requestPayload)}`, signal: controller.signal, }); if (!response.ok) { const isProd = process.env["NODE_ENV"] === "production"; const bodySnippet = isProd ? undefined : await this.safeReadBodySnippet(response); this.logger.error("Freebit API HTTP error", { url, status: response.status, statusText: response.statusText, ...(bodySnippet ? { responseBodySnippet: bodySnippet } : {}), attempt, }); throw new FreebitError( `HTTP ${response.status}: ${response.statusText}`, response.status.toString() ); } const responseData = (await response.json()) as TResponse; const resultCode = this.normalizeResultCode(responseData.resultCode); const statusCode = this.normalizeResultCode(responseData.status?.statusCode); if (resultCode && resultCode !== "100") { const isProd = process.env["NODE_ENV"] === "production"; const errorDetails = { url, resultCode, statusCode, statusMessage: responseData.status?.message, ...(isProd ? {} : { fullResponse: JSON.stringify(responseData) }), }; this.logger.error("Freebit API returned error response", errorDetails); // Also log to console for visibility in dev if (!isProd) { console.error("[FREEBIT ERROR]", JSON.stringify(errorDetails, null, 2)); } throw new FreebitError( `API Error: ${responseData.status?.message || "Unknown error"}`, resultCode, statusCode, responseData.status?.message ); } this.logger.debug("Freebit API request successful", { url, resultCode }); return responseData; } finally { clearTimeout(timeoutId); } }, { maxAttempts: config.retryAttempts, baseDelayMs: 1000, maxDelayMs: 10000, isRetryable: error => { if (error instanceof FreebitError) { if (error.isAuthError() && attempt === 1) { this.logger.warn("Freebit auth error detected, clearing cache and retrying", { url, }); this.authService.clearAuthCache(); return true; } return error.isRetryable(); } return RetryableErrors.isTransientError(error); }, logger: this.logger, logContext: "Freebit API request", } ); // Track successful API call this.trackApiCall(endpoint, payload, responseData, null).catch((error: unknown) => { this.logger.debug("Failed to track API call", { error: error instanceof Error ? error.message : String(error), }); }); return responseData; } catch (error: unknown) { // Track failed API call this.trackApiCall(endpoint, payload, null, error).catch((trackError: unknown) => { this.logger.debug("Failed to track API call error", { error: trackError instanceof Error ? trackError.message : String(trackError), }); }); throw error; } } /** * Make an authenticated JSON request to Freebit API (for PA05-38, PA05-41, etc.) */ async makeAuthenticatedJsonRequest< TResponse extends FreebitResponseBase, TPayload extends object, >(endpoint: string, payload: TPayload): Promise { const config = this.authService.getConfig(); const authKey = await this.authService.getAuthKey(); const url = this.buildUrl(config.baseUrl, endpoint); // Add authKey to the payload for authentication const requestPayload = { ...payload, authKey }; let attempt = 0; // Log request details in dev for debugging const isProd = process.env["NODE_ENV"] === "production"; if (!isProd) { this.logger.debug("[FREEBIT JSON API REQUEST]", { url, payload: redactForLogs(requestPayload), }); } try { const responseData = await withRetry( async () => { attempt += 1; this.logger.debug("Freebit JSON API request", { url, attempt, maxAttempts: config.retryAttempts, payload: redactForLogs(requestPayload), }); const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), config.timeout); try { const response = await fetch(url, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(requestPayload), signal: controller.signal, }); if (!response.ok) { throw new FreebitError( `HTTP ${response.status}: ${response.statusText}`, response.status.toString() ); } const responseData = (await response.json()) as TResponse; const resultCode = this.normalizeResultCode(responseData.resultCode); const statusCode = this.normalizeResultCode(responseData.status?.statusCode); if (resultCode && resultCode !== "100") { const isProd = process.env["NODE_ENV"] === "production"; const errorDetails = { url, resultCode, statusCode, message: responseData.status?.message, ...(isProd ? {} : { response: redactForLogs(responseData as unknown) }), attempt, }; this.logger.error("Freebit JSON API returned error result code", errorDetails); // Always log to console in dev for visibility if (!isProd) { console.error("[FREEBIT JSON API ERROR]", JSON.stringify(errorDetails, null, 2)); } throw new FreebitError( `API Error: ${responseData.status?.message || "Unknown error"}`, resultCode, statusCode, responseData.status?.message ); } this.logger.debug("Freebit JSON API request successful", { url, resultCode }); return responseData; } finally { clearTimeout(timeoutId); } }, { maxAttempts: config.retryAttempts, baseDelayMs: 1000, maxDelayMs: 10000, isRetryable: error => { if (error instanceof FreebitError) { if (error.isAuthError() && attempt === 1) { this.logger.warn("Freebit auth error detected, clearing cache and retrying", { url, }); this.authService.clearAuthCache(); return true; } return error.isRetryable(); } return RetryableErrors.isTransientError(error); }, logger: this.logger, logContext: "Freebit JSON API request", } ); // Track successful API call this.trackApiCall(endpoint, payload, responseData, null).catch((error: unknown) => { this.logger.debug("Failed to track API call", { error: error instanceof Error ? error.message : String(error), }); }); return responseData; } catch (error: unknown) { // Track failed API call this.trackApiCall(endpoint, payload, null, error).catch((trackError: unknown) => { this.logger.debug("Failed to track API call error", { error: trackError instanceof Error ? trackError.message : String(trackError), }); }); throw error; } } /** * Make a simple request without authentication (for health checks) */ async makeSimpleRequest(endpoint: string): Promise { const config = this.authService.getConfig(); const url = this.buildUrl(config.baseUrl, endpoint); try { const controller = new AbortController(); const timeout = setTimeout(() => controller.abort(), config.timeout); const response = await fetch(url, { method: "GET", signal: controller.signal, }); clearTimeout(timeout); return response.ok; } catch (error) { this.logger.debug("Simple request failed", { url, error: extractErrorMessage(error), }); return false; } } /** * Ensure proper URL construction - remove double slashes */ private buildUrl(baseUrl: string, endpoint: string): string { const cleanBase = baseUrl.replace(/\/$/, ""); const cleanEndpoint = endpoint.startsWith("/") ? endpoint : `/${endpoint}`; return `${cleanBase}${cleanEndpoint}`; } private async safeReadBodySnippet(response: Response): Promise { try { const text = await response.text(); return text ? text.slice(0, 300) : undefined; } catch { return undefined; } } private normalizeResultCode(code?: string | number | null): string | undefined { if (code === undefined || code === null) { return undefined; } const normalized = String(code).trim(); return normalized.length > 0 ? normalized : undefined; } /** * Track API call for testing purposes */ private async trackApiCall( endpoint: string, payload: unknown, response: FreebitResponseBase | null, error: unknown ): Promise { const payloadObj = payload as Record; const phoneNumber = this.testTracker.extractPhoneNumber( (payloadObj["account"] as string) || "", payload ); // Only track if we have a phone number (SIM-related calls) if (!phoneNumber) { return; } const timestamp = this.testTracker.getCurrentTimestamp(); const resultCode = response?.resultCode ? String(response.resultCode) : (error instanceof FreebitError ? String(error.resultCode || "ERROR") : "ERROR"); const statusMessage = response?.status?.message || (error instanceof FreebitError ? error.message : (error ? extractErrorMessage(error) : "Success")); await this.testTracker.logApiCall({ timestamp, apiEndpoint: endpoint, apiMethod: "POST", phoneNumber, simIdentifier: (payloadObj["account"] as string) || phoneNumber, requestPayload: JSON.stringify(redactForLogs(payload)), responseStatus: resultCode === "100" ? "Success" : `Error: ${resultCode}`, ...(error ? { error: extractErrorMessage(error) } : {}), additionalInfo: statusMessage, }); } }