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"; interface FreebitResponseBase { resultCode?: string | number; status?: { message?: string; statusCode?: string | number; }; } @Injectable() export class FreebitClientService { constructor( private readonly authService: FreebitAuthService, @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; return 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"; this.logger.warn("Freebit API returned error response", { url, resultCode, statusCode, statusMessage: responseData.status?.message, ...(isProd ? {} : { response: redactForLogs(responseData as unknown) }), }); 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", } ); } /** * Make an authenticated JSON request to Freebit API (for PA05-41) */ async makeAuthenticatedJsonRequest< TResponse extends FreebitResponseBase, TPayload extends object, >(endpoint: string, payload: TPayload): Promise { const config = this.authService.getConfig(); const url = this.buildUrl(config.baseUrl, endpoint); let attempt = 0; return withRetry( async () => { attempt += 1; this.logger.debug("Freebit JSON API request", { url, attempt, maxAttempts: config.retryAttempts, payload: redactForLogs(payload), }); 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(payload), 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"; this.logger.error("Freebit API returned error result code", { url, resultCode, statusCode, message: responseData.status?.message, ...(isProd ? {} : { response: redactForLogs(responseData as unknown) }), attempt, }); 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", } ); } /** * 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; } }