diff --git a/apps/bff/src/infra/audit/audit.service.ts b/apps/bff/src/infra/audit/audit.service.ts index 4d9a7a31..b8ad887c 100644 --- a/apps/bff/src/infra/audit/audit.service.ts +++ b/apps/bff/src/infra/audit/audit.service.ts @@ -69,7 +69,12 @@ export class AuditService { action: AuditAction, userId?: string, details?: Record | string | number | boolean | null, - request?: { headers?: Record }, + request?: { + headers?: Record; + ip?: string; + connection?: { remoteAddress?: string }; + socket?: { remoteAddress?: string }; + }, success: boolean = true, error?: string ): Promise { diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index 5cc5f04a..1651fde5 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -1,11 +1,7 @@ import { getErrorMessage } from "@bff/core/utils/error.util"; import { Logger } from "nestjs-pino"; import { Injectable, NotFoundException, Inject } from "@nestjs/common"; -import { Invoice, InvoiceList } from "@customer-portal/domain"; -import { - invoiceListSchema, - invoiceSchema, -} from "@customer-portal/domain/validation/shared/entities"; +import { Invoice, InvoiceList, invoiceListSchema, invoiceSchema } from "@customer-portal/domain"; import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service"; import { InvoiceTransformerService } from "../transformers/services/invoice-transformer.service"; import { WhmcsCacheService } from "../cache/whmcs-cache.service"; diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts index d5bdff60..e9a3968c 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts @@ -165,34 +165,17 @@ export class WhmcsOrderService { */ async hasPaymentMethod(clientId: number): Promise { try { - const response = (await this.connection.getPaymentMethods({ + const response = await this.connection.getPaymentMethods({ clientid: clientId, - })) as unknown as Record; + }); - if (response.result !== "success") { - this.logger.warn("Failed to check payment methods", { - clientId, - error: response.message as string, - }); - return false; - } - - // Check if client has any payment methods - const paymethodsNode = (response.paymethods as { paymethod?: unknown } | undefined) - ?.paymethod; - const totalResults = Number((response as { totalresults?: unknown })?.totalresults ?? 0) || 0; - const methodCount = Array.isArray(paymethodsNode) - ? paymethodsNode.length - : paymethodsNode && typeof paymethodsNode === "object" - ? 1 - : 0; - const hasValidMethod = methodCount > 0 || totalResults > 0; + const methods = Array.isArray(response.paymethods) ? response.paymethods : []; + const hasValidMethod = methods.length > 0; this.logger.log("Payment method check completed", { clientId, hasPaymentMethod: hasValidMethod, - methodCount, - totalResults, + methodCount: methods.length, }); return hasValidMethod; diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts index 9cb4559a..bdb6b9d2 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts @@ -48,15 +48,6 @@ export class WhmcsPaymentService { clientid: clientId, }); - if (!response || response.result !== "success") { - const message = response?.message ?? "GetPayMethods call failed"; - this.logger.error("WHMCS GetPayMethods returned error", { - clientId, - response, - }); - throw new Error(message); - } - const paymentMethodsArray: WhmcsPaymentMethod[] = Array.isArray(response.paymethods) ? response.paymethods : []; diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts index b01db752..affb7659 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts @@ -58,7 +58,15 @@ export class WhmcsSubscriptionService { const response = await this.connectionService.getClientsProducts(params); - // Debug logging to understand the response structure + if (!response || response.result !== "success") { + const message = response?.message || "GetClientsProducts call failed"; + this.logger.error("WHMCS GetClientsProducts returned error", { + clientId, + response, + }); + throw new Error(message); + } + const productContainer = response.products?.product; const products = Array.isArray(productContainer) ? productContainer @@ -67,10 +75,9 @@ export class WhmcsSubscriptionService { : []; this.logger.debug(`WHMCS GetClientsProducts response structure for client ${clientId}`, { - hasResponse: Boolean(response), - responseKeys: response ? Object.keys(response) : [], - hasProducts: Boolean(response.products), - productType: productContainer ? typeof productContainer : "undefined", + totalresults: response.totalresults, + startnumber: response.startnumber, + numreturned: response.numreturned, productCount: products.length, }); diff --git a/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts index cf3363a3..6a92f322 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts @@ -34,8 +34,8 @@ export class SubscriptionTransformerService { const subscription: Subscription = { id: Number(whmcsProduct.id), serviceId: Number(whmcsProduct.id), // In WHMCS, product ID is the service ID - productName: whmcsProduct.productname || whmcsProduct.name, - domain: whmcsProduct.domain, + productName: whmcsProduct.name || "", + domain: whmcsProduct.domain || undefined, status: this.mapSubscriptionStatus(whmcsProduct.status), cycle: billingCycle, amount: this.getProductAmount(whmcsProduct), @@ -43,7 +43,7 @@ export class SubscriptionTransformerService { currencySymbol: defaultCurrency.prefix || defaultCurrency.suffix, nextDue: DataUtils.formatDate(whmcsProduct.nextduedate), registrationDate: DataUtils.formatDate(whmcsProduct.regdate) || new Date().toISOString(), - customFields: this.extractCustomFields(whmcsProduct.customfields), + customFields: this.extractCustomFields(whmcsProduct.customfields?.customfield), notes: undefined, // WhmcsProduct doesn't have notes field }; @@ -72,7 +72,7 @@ export class SubscriptionTransformerService { error: message, productId: whmcsProduct.id, status: whmcsProduct.status, - productName: whmcsProduct.name || whmcsProduct.productname, + productName: whmcsProduct.name || whmcsProduct.name, }); throw new Error(`Failed to transform subscription: ${message}`); } @@ -117,40 +117,53 @@ export class SubscriptionTransformerService { /** * Normalize field name to camelCase */ - private mapSubscriptionStatus(status: string): Subscription["status"] { - const allowed: Subscription["status"][] = [ - "Active", - "Suspended", - "Terminated", - "Cancelled", - "Pending", - "Completed", - ]; - - if (allowed.includes(status as Subscription["status"])) { - return status as Subscription["status"]; + private mapSubscriptionStatus(status: string | undefined): Subscription["status"] { + if (typeof status !== "string") { + return "Cancelled"; } - throw new Error(`Unsupported WHMCS subscription status: ${status}`); + const normalized = status.trim().toLowerCase(); + + const statusMap: Record = { + active: "Active", + completed: "Completed", + cancelled: "Cancelled", + canceled: "Cancelled", + terminated: "Cancelled", + suspended: "Cancelled", + pending: "Active", + fraud: "Cancelled", + }; + + return statusMap[normalized] ?? "Cancelled"; } - private mapBillingCycle(cycle: string): Subscription["cycle"] { - const allowed: Subscription["cycle"][] = [ - "Monthly", - "Quarterly", - "Semi-Annually", - "Annually", - "Biennially", - "Triennially", - "One-time", - "Free", - ]; - - if (allowed.includes(cycle as Subscription["cycle"])) { - return cycle as Subscription["cycle"]; + private mapBillingCycle(cycle: string | undefined): Subscription["cycle"] { + if (typeof cycle !== "string" || cycle.trim().length === 0) { + return "One-time"; } - throw new Error(`Unsupported WHMCS billing cycle: ${cycle}`); + const normalized = cycle.trim().toLowerCase().replace(/[_\s-]+/g, " "); + + const cycleMap: Record = { + monthly: "Monthly", + annually: "Annually", + annual: "Annually", + yearly: "Annually", + quarterly: "Quarterly", + "semi annually": "Semi-Annually", + "semiannually": "Semi-Annually", + "semi-annually": "Semi-Annually", + biennially: "Biennially", + triennially: "Triennially", + "one time": "One-time", + onetime: "One-time", + "one-time": "One-time", + "one time fee": "One-time", + free: "Free", + }; + + return cycleMap[normalized] ?? "One-time"; } /** diff --git a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts index 1c43a9bc..28a9e611 100644 --- a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts +++ b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts @@ -53,7 +53,6 @@ export interface WhmcsCustomField { value: string; // Legacy fields that may appear in some responses name?: string; - translated_name?: string; type?: string; } @@ -117,74 +116,72 @@ export interface WhmcsInvoiceResponse extends WhmcsInvoice { // Product/Service Types export interface WhmcsProductsResponse { - products: { - product: WhmcsProduct | WhmcsProduct[]; - }; - totalresults?: number; + result: "success" | "error"; + message?: string; + clientid?: number | string; + serviceid?: number | string | null; + pid?: number | string | null; + domain?: string | null; + totalresults?: number | string; + startnumber?: number; numreturned?: number; + products?: { + product?: WhmcsProduct | WhmcsProduct[]; + }; } export interface WhmcsProduct { - id: number; - clientid: number; - orderid: number; - pid: number; - regdate: string; - name: string; + id: number | string; + qty?: string; + clientid?: number | string; + orderid?: number | string; + ordernumber?: string; + pid?: number | string; + regdate?: string; + name?: string; translated_name?: string; groupname?: string; - productname?: string; translated_groupname?: string; - domain: string; + domain?: string; dedicatedip?: string; - serverid?: number; + serverid?: number | string; servername?: string; serverip?: string; serverhostname?: string; suspensionreason?: string; - promoid?: number; - producttype?: string; - modulename?: string; - billingcycle: - | "Monthly" - | "Quarterly" - | "Semi-Annually" - | "Annually" - | "Biennially" - | "Triennially" - | "One-time" - | "Free"; - nextduedate: string; - status: "Active" | "Suspended" | "Terminated" | "Cancelled" | "Pending" | "Completed"; + firstpaymentamount?: string; + recurringamount?: string; + paymentmethod?: string; + paymentmethodname?: string; + billingcycle?: string; + nextduedate?: string; + status?: string; username?: string; password?: string; subscriptionid?: string; - promotype?: string; - promocode?: string; - promodescription?: string; - promovalue?: string; - packageid?: number; - packagename?: string; - configoptions?: Record; - customfields?: WhmcsCustomField[]; - firstpaymentamount: string; - recurringamount: string; - paymentmethod: string; - paymentmethodname?: string; - currencycode?: string; - currencyprefix?: string; - currencysuffix?: string; - overideautosuspend?: boolean; + promoid?: string; + overideautosuspend?: string; overidesuspenduntil?: string; ns1?: string; ns2?: string; assignedips?: string; - disk?: string; - disklimit?: string; + notes?: string; diskusage?: string; - bwlimit?: string; + disklimit?: string; bwusage?: string; + bwlimit?: string; lastupdate?: string; + customfields?: { + customfield?: WhmcsCustomField[]; + }; + configoptions?: { + configoption?: Array<{ + id?: number | string; + option?: string; + type?: string; + value?: string; + }>; + }; } // SSO Token Types @@ -313,8 +310,7 @@ export interface WhmcsPaymentMethod { } export interface WhmcsPayMethodsResponse { - result: "success" | "error"; - clientid?: number | string; + clientid: number | string; paymethods?: WhmcsPaymentMethod[]; message?: string; } @@ -383,7 +379,7 @@ export interface WhmcsCreateInvoiceResponse { // UpdateInvoice API Types export interface WhmcsUpdateInvoiceParams { invoiceid: number; - status?: "Draft" | "Paid" | "Unpaid" | "Cancelled" | "Refunded" | "Collections" | "Overdue"; + status?: "Draft" | "Paid" | "Unpaid" | "Cancelled" | "Refunded" | "Collections" | "Overdue"; duedate?: string; // YYYY-MM-DD format notes?: string; [key: string]: unknown; diff --git a/apps/bff/src/integrations/whmcs/whmcs.service.ts b/apps/bff/src/integrations/whmcs/whmcs.service.ts index 40128da2..b1f2e0bf 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.service.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.service.ts @@ -114,42 +114,6 @@ export class WhmcsService { return this.subscriptionService.invalidateSubscriptionCache(userId, subscriptionId); } - /** - * Get subscription statistics for a client - */ - async getSubscriptionStats( - clientId: number, - userId: string - ): Promise<{ - total: number; - active: number; - suspended: number; - cancelled: number; - pending: number; - }> { - try { - const subscriptionList = await this.subscriptionService.getSubscriptions(clientId, userId); - const subscriptions: Subscription[] = subscriptionList.subscriptions; - - const stats = { - total: subscriptions.length, - active: subscriptions.filter((s: Subscription) => s.status === "Active").length, - suspended: subscriptions.filter((s: Subscription) => s.status === "Suspended").length, - cancelled: subscriptions.filter((s: Subscription) => s.status === "Cancelled").length, - pending: subscriptions.filter((s: Subscription) => s.status === "Pending").length, - }; - - this.logger.debug(`Generated subscription stats for client ${clientId}:`, stats); - return stats; - } catch (error) { - this.logger.error(`Failed to get subscription stats for client ${clientId}`, { - error: getErrorMessage(error), - userId, - }); - throw error; - } - } - // ========================================== // CLIENT OPERATIONS (delegate to ClientService) // ========================================== diff --git a/apps/bff/src/modules/auth/application/auth.facade.ts b/apps/bff/src/modules/auth/application/auth.facade.ts index e3f9e033..46fb5fb1 100644 --- a/apps/bff/src/modules/auth/application/auth.facade.ts +++ b/apps/bff/src/modules/auth/application/auth.facade.ts @@ -295,7 +295,7 @@ export class AuthFacade { } } - async logout(userId: string, token?: string, _request?: Request): Promise { + async logout(userId: string, token?: string, request?: Request): Promise { if (token) { await this.tokenBlacklistService.blacklistToken(token); } @@ -309,7 +309,7 @@ export class AuthFacade { }); } - await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, _request, true); + await this.auditService.logAuthEvent(AuditAction.LOGOUT, userId, {}, request, true); } // Helper methods diff --git a/apps/bff/src/modules/auth/infra/rate-limiting/auth-rate-limit.service.ts b/apps/bff/src/modules/auth/infra/rate-limiting/auth-rate-limit.service.ts index 4645b4b6..5ee65ba5 100644 --- a/apps/bff/src/modules/auth/infra/rate-limiting/auth-rate-limit.service.ts +++ b/apps/bff/src/modules/auth/infra/rate-limiting/auth-rate-limit.service.ts @@ -41,10 +41,7 @@ export class AuthRateLimitService { const signupLimit = this.configService.get("SIGNUP_RATE_LIMIT_LIMIT", 5); const signupTtlMs = this.configService.get("SIGNUP_RATE_LIMIT_TTL", 900000); - const passwordResetLimit = this.configService.get( - "PASSWORD_RESET_RATE_LIMIT_LIMIT", - 5 - ); + const passwordResetLimit = this.configService.get("PASSWORD_RESET_RATE_LIMIT_LIMIT", 5); const passwordResetTtlMs = this.configService.get( "PASSWORD_RESET_RATE_LIMIT_TTL", 900000 @@ -53,10 +50,7 @@ export class AuthRateLimitService { const refreshLimit = this.configService.get("AUTH_REFRESH_RATE_LIMIT_LIMIT", 10); const refreshTtlMs = this.configService.get("AUTH_REFRESH_RATE_LIMIT_TTL", 300000); - this.loginCaptchaThreshold = this.configService.get( - "LOGIN_CAPTCHA_AFTER_ATTEMPTS", - 3 - ); + this.loginCaptchaThreshold = this.configService.get("LOGIN_CAPTCHA_AFTER_ATTEMPTS", 3); this.captchaAlwaysOn = this.configService.get("AUTH_CAPTCHA_ALWAYS_ON", "false") === "true"; this.loginLimiter = this.createLimiter("auth-login", loginLimit, loginTtlMs); @@ -188,11 +182,7 @@ export class AuthRateLimitService { } } - private async deleteKey( - limiter: RateLimiterRedis, - key: string, - context: string - ): Promise { + private async deleteKey(limiter: RateLimiterRedis, key: string, context: string): Promise { try { await limiter.delete(key); } catch (error: unknown) { diff --git a/apps/bff/src/modules/auth/infra/token/token-migration.service.ts b/apps/bff/src/modules/auth/infra/token/token-migration.service.ts index abd8015b..9023e2a8 100644 --- a/apps/bff/src/modules/auth/infra/token/token-migration.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token-migration.service.ts @@ -78,7 +78,7 @@ export class TokenMigrationService { stats.errors++; this.logger.error("Token migration failed", { - error: error instanceof Error ? error.message : String(error), + error: getErrorMessage(error), stats, }); @@ -106,7 +106,7 @@ export class TokenMigrationService { stats.errors++; this.logger.error("Failed to migrate family", { key, - error: error instanceof Error ? error.message : String(error), + error: getErrorMessage(error), }); } } @@ -188,7 +188,7 @@ export class TokenMigrationService { stats.errors++; this.logger.error("Failed to migrate token", { key, - error: error instanceof Error ? error.message : String(error), + error: getErrorMessage(error), }); } } diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts index ce299903..675e413b 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/whmcs-link-workflow.service.ts @@ -48,16 +48,18 @@ export class WhmcsLinkWorkflowService { try { clientDetails = await this.whmcsService.getClientDetailsByEmail(email); } catch (error) { - this.logger.error("WHMCS client lookup failed", { + this.logger.error("WHMCS client lookup failed", { error: getErrorMessage(error), - email: email // Safe to log email for debugging since it's not sensitive + email, // Safe to log email for debugging since it's not sensitive }); - + // Provide more specific error messages based on the error type - if (error instanceof Error && error.message.includes('not found')) { - throw new UnauthorizedException("No billing account found with this email address. Please check your email or contact support."); + if (error instanceof Error && error.message.includes("not found")) { + throw new UnauthorizedException( + "No billing account found with this email address. Please check your email or contact support." + ); } - + throw new UnauthorizedException("Unable to verify account. Please try again later."); } @@ -79,18 +81,24 @@ export class WhmcsLinkWorkflowService { } } catch (error) { if (error instanceof UnauthorizedException) throw error; - + const errorMessage = getErrorMessage(error); this.logger.error("WHMCS credential validation failed", { error: errorMessage }); - + // Check if this is a WHMCS authentication error and provide user-friendly message - if (errorMessage.toLowerCase().includes('email or password invalid') || - errorMessage.toLowerCase().includes('invalid email or password') || - errorMessage.toLowerCase().includes('authentication failed') || - errorMessage.toLowerCase().includes('login failed')) { - throw new UnauthorizedException("Invalid email or password. Please check your credentials and try again."); + const normalizedMessage = errorMessage.toLowerCase(); + const authErrorPhrases = [ + "email or password invalid", + "invalid email or password", + "authentication failed", + "login failed", + ]; + if (authErrorPhrases.some(phrase => normalizedMessage.includes(phrase))) { + throw new UnauthorizedException( + "Invalid email or password. Please check your credentials and try again." + ); } - + // For other errors, provide generic message to avoid exposing system details throw new UnauthorizedException("Unable to verify credentials. Please try again later."); } diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index 212b46e0..d6ae3d92 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -47,7 +47,9 @@ import { } from "@customer-portal/domain"; import type { AuthTokens } from "@customer-portal/domain"; -type RequestWithCookies = Request & { cookies?: Record }; +type RequestWithCookies = Request & { + cookies: Record; +}; const resolveAuthorizationHeader = (req: RequestWithCookies): string | undefined => { const rawHeader = req.headers?.authorization; @@ -197,9 +199,11 @@ export class AuthController { @Res({ passthrough: true }) res: Response ) { const refreshToken = body.refreshToken ?? req.cookies?.refresh_token; + const rawUserAgent = req.headers["user-agent"]; + const userAgent = typeof rawUserAgent === "string" ? rawUserAgent : undefined; const result = await this.authFacade.refreshTokens(refreshToken, { deviceId: body.deviceId, - userAgent: req.headers["user-agent"], + userAgent, }); this.setAuthCookies(res, result.tokens); return result; diff --git a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts index ee22718e..b4d4f36c 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts @@ -15,7 +15,9 @@ import { TokenBlacklistService } from "../../../infra/token/token-blacklist.serv import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator"; import { getErrorMessage } from "@bff/core/utils/error.util"; -type RequestWithCookies = Request & { cookies?: Record }; +type RequestWithCookies = Request & { + cookies: Record; +}; type RequestWithRoute = RequestWithCookies & { method: string; url: string; @@ -44,8 +46,8 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate { } override async canActivate(context: ExecutionContext): Promise { - const request = context.switchToHttp().getRequest(); - const route = `${request.method} ${request.route?.path ?? request.url}`; + const request = this.getRequest(context); + const route = `${request.method} ${request.url}`; // Check if the route is marked as public const isPublic = this.reflector.getAllAndOverride(IS_PUBLIC_KEY, [ @@ -84,4 +86,24 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate { throw error; } } + override getRequest(context: ExecutionContext): RequestWithRoute { + const rawRequest = context.switchToHttp().getRequest(); + if (!this.isRequestWithRoute(rawRequest)) { + this.logger.error("Unable to determine HTTP request in auth guard"); + throw new UnauthorizedException("Invalid request context"); + } + return rawRequest; + } + + private isRequestWithRoute(value: unknown): value is RequestWithRoute { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as Partial; + return ( + typeof candidate.method === "string" && + typeof candidate.url === "string" && + (!candidate.route || typeof candidate.route === "object") + ); + } } diff --git a/apps/bff/src/modules/auth/presentation/http/interceptors/login-result.interceptor.ts b/apps/bff/src/modules/auth/presentation/http/interceptors/login-result.interceptor.ts index b50e7a42..1c17f40f 100644 --- a/apps/bff/src/modules/auth/presentation/http/interceptors/login-result.interceptor.ts +++ b/apps/bff/src/modules/auth/presentation/http/interceptors/login-result.interceptor.ts @@ -5,7 +5,7 @@ import { CallHandler, UnauthorizedException, } from "@nestjs/common"; -import { Observable, throwError } from "rxjs"; +import { Observable, defer } from "rxjs"; import { tap, catchError } from "rxjs/operators"; import { FailedLoginThrottleGuard } from "@bff/modules/auth/presentation/http/guards/failed-login-throttle.guard"; import type { Request } from "express"; @@ -14,27 +14,74 @@ import type { Request } from "express"; export class LoginResultInterceptor implements NestInterceptor { constructor(private readonly failedLoginGuard: FailedLoginThrottleGuard) {} - intercept(context: ExecutionContext, next: CallHandler): Observable { - const request = context.switchToHttp().getRequest(); + intercept(context: ExecutionContext, next: CallHandler): Observable { + const rawRequest = context.switchToHttp().getRequest(); + if (!this.isExpressRequest(rawRequest)) { + throw new UnauthorizedException("Invalid request context"); + } + const request: Request = rawRequest; return next.handle().pipe( - tap(async () => { - await this.failedLoginGuard.handleLoginResult(request, true); + tap(() => { + void this.failedLoginGuard.handleLoginResult(request, true); }), - catchError(async error => { - const message = typeof error?.message === "string" ? error.message.toLowerCase() : ""; - const isAuthError = - error instanceof UnauthorizedException || - error?.status === 401 || - message.includes("invalid") || - message.includes("unauthorized"); + catchError(error => + defer(async () => { + const message = this.extractErrorMessage(error).toLowerCase(); + const status = this.extractStatusCode(error); + const isAuthError = + error instanceof UnauthorizedException || + status === 401 || + message.includes("invalid") || + message.includes("unauthorized"); - if (isAuthError) { - await this.failedLoginGuard.handleLoginResult(request, false); - } + if (isAuthError) { + await this.failedLoginGuard.handleLoginResult(request, false); + } - return throwError(() => error); - }) + throw error; + }) + ) ); } + + private isExpressRequest(value: unknown): value is Request { + if (!value || typeof value !== "object") { + return false; + } + const candidate = value as Partial & { + headers?: unknown; + method?: unknown; + url?: unknown; + }; + return ( + typeof candidate.headers === "object" && + candidate.headers !== null && + typeof candidate.method === "string" && + typeof candidate.url === "string" + ); + } + + private extractErrorMessage(error: unknown): string { + if (typeof error === "string") { + return error; + } + if (error && typeof error === "object" && "message" in error) { + const maybeMessage = (error as { message?: unknown }).message; + if (typeof maybeMessage === "string") { + return maybeMessage; + } + } + return ""; + } + + private extractStatusCode(error: unknown): number | undefined { + if (error && typeof error === "object" && "status" in error) { + const statusValue = (error as { status?: unknown }).status; + if (typeof statusValue === "number") { + return statusValue; + } + } + return undefined; + } } diff --git a/apps/bff/src/modules/auth/presentation/index.ts b/apps/bff/src/modules/auth/presentation/index.ts index a261904d..9d93bbeb 100644 --- a/apps/bff/src/modules/auth/presentation/index.ts +++ b/apps/bff/src/modules/auth/presentation/index.ts @@ -1,2 +1 @@ export * from "./http/auth.controller"; - diff --git a/apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts b/apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts index 9bb485d6..272f09b9 100644 --- a/apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts +++ b/apps/bff/src/modules/auth/presentation/strategies/jwt.strategy.ts @@ -8,11 +8,12 @@ import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util"; import type { Request } from "express"; const cookieExtractor = (req: Request): string | null => { - const cookieToken = req?.cookies?.access_token; - if (typeof cookieToken === "string" && cookieToken.length > 0) { - return cookieToken; + const cookieSource: unknown = Reflect.get(req, "cookies"); + if (!cookieSource || typeof cookieSource !== "object") { + return null; } - return null; + const token = Reflect.get(cookieSource, "access_token") as unknown; + return typeof token === "string" && token.length > 0 ? token : null; }; @Injectable() diff --git a/apps/bff/src/modules/orders/services/order-item-builder.service.ts b/apps/bff/src/modules/orders/services/order-item-builder.service.ts index a6c685fe..64936491 100644 --- a/apps/bff/src/modules/orders/services/order-item-builder.service.ts +++ b/apps/bff/src/modules/orders/services/order-item-builder.service.ts @@ -2,7 +2,7 @@ import { Injectable, BadRequestException, NotFoundException, Inject } from "@nes import { Logger } from "nestjs-pino"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { OrderPricebookService } from "./order-pricebook.service"; -import type { PrismaService } from "@bff/infra/database/prisma.service"; +import { PrismaService } from "@bff/infra/database/prisma.service"; import { createOrderRequestSchema } from "@customer-portal/domain"; /** diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index a084d90b..c123e5cf 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -62,9 +62,8 @@ export class SubscriptionsController { async getSubscriptionStats(@Request() req: RequestWithUser): Promise<{ total: number; active: number; - suspended: number; + completed: number; cancelled: number; - pending: number; }> { return this.subscriptionsService.getSubscriptionStats(req.user.id); } diff --git a/apps/bff/src/modules/subscriptions/subscriptions.service.ts b/apps/bff/src/modules/subscriptions/subscriptions.service.ts index 66c0b0bb..04b4abb4 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.service.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.service.ts @@ -7,10 +7,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { Logger } from "nestjs-pino"; import { z } from "zod"; import { subscriptionSchema } from "@customer-portal/domain"; -import type { - WhmcsProduct, - WhmcsProductsResponse, -} from "@bff/integrations/whmcs/types/whmcs-api.types"; +import type { WhmcsProduct } from "@bff/integrations/whmcs/types/whmcs-api.types"; export interface GetSubscriptionsOptions { status?: string; @@ -194,13 +191,8 @@ export class SubscriptionsService { async getSubscriptionStats(userId: string): Promise<{ total: number; active: number; - suspended: number; - cancelled: number; - pending: number; completed: number; - totalMonthlyRevenue: number; - activeMonthlyRevenue: number; - currency: string; + cancelled: number; }> { try { // Get WHMCS client ID from user mapping @@ -209,39 +201,18 @@ export class SubscriptionsService { throw new NotFoundException("WHMCS client mapping not found"); } - // Get basic stats from WHMCS service - const basicStats = await this.whmcsService.getSubscriptionStats( - mapping.whmcsClientId, - userId - ); - - // Get all subscriptions for financial calculations + // Get all subscriptions and aggregate customer-facing stats only const subscriptionList = await this.getSubscriptions(userId); const subscriptions: Subscription[] = subscriptionList.subscriptions; - // Calculate revenue metrics - const totalMonthlyRevenue = subscriptions - .filter(s => s.cycle === "Monthly") - .reduce((sum, s) => sum + s.amount, 0); - - const activeMonthlyRevenue = subscriptions - .filter(s => s.status === "Active" && s.cycle === "Monthly") - .reduce((sum, s) => sum + s.amount, 0); - const stats = { - ...basicStats, + total: subscriptions.length, + active: subscriptions.filter(s => s.status === "Active").length, completed: subscriptions.filter(s => s.status === "Completed").length, - totalMonthlyRevenue, - activeMonthlyRevenue, - currency: subscriptions[0]?.currency || "JPY", + cancelled: subscriptions.filter(s => s.status === "Cancelled").length, }; - this.logger.log(`Generated subscription stats for user ${userId}`, { - ...stats, - // Don't log revenue amounts for security - totalMonthlyRevenue: "[CALCULATED]", - activeMonthlyRevenue: "[CALCULATED]", - }); + this.logger.log(`Generated subscription stats for user ${userId}`, stats); return stats; } catch (error) { @@ -511,7 +482,7 @@ export class SubscriptionsService { return false; } - const productsResponse: WhmcsProductsResponse = await this.whmcsService.getClientsProducts({ + const productsResponse = await this.whmcsService.getClientsProducts({ clientid: mapping.whmcsClientId, }); const productContainer = productsResponse.products?.product; diff --git a/apps/bff/src/types/rate-limiter-flexible.d.ts b/apps/bff/src/types/rate-limiter-flexible.d.ts index 4965db95..4097c741 100644 --- a/apps/bff/src/types/rate-limiter-flexible.d.ts +++ b/apps/bff/src/types/rate-limiter-flexible.d.ts @@ -15,11 +15,7 @@ declare module "rate-limiter-flexible" { remainingPoints: number; consumedPoints: number; msBeforeNext: number; - constructor(data: { - remainingPoints: number; - consumedPoints: number; - msBeforeNext: number; - }); + constructor(data: { remainingPoints: number; consumedPoints: number; msBeforeNext: number }); } export class RateLimiterRedis { diff --git a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts index 85f3ec24..64c679a8 100644 --- a/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts +++ b/apps/portal/src/features/subscriptions/hooks/useSubscriptions.ts @@ -20,9 +20,8 @@ const emptySubscriptionList: SubscriptionList = { const emptyStats = { total: 0, active: 0, - suspended: 0, + completed: 0, cancelled: 0, - pending: 0, }; const emptyInvoiceList: InvoiceList = { diff --git a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx index f4199165..533dfb90 100644 --- a/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx +++ b/apps/portal/src/features/subscriptions/views/SubscriptionsList.tsx @@ -16,7 +16,6 @@ import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { ServerIcon, CheckCircleIcon, - ExclamationTriangleIcon, ClockIcon, XCircleIcon, CalendarIcon, @@ -59,12 +58,9 @@ export function SubscriptionsListContainer() { switch (status) { case "Active": return ; - case "Suspended": - return ; - case "Pending": - return ; + case "Completed": + return ; case "Cancelled": - case "Terminated": return ; default: return ; @@ -82,22 +78,17 @@ export function SubscriptionsListContainer() { const statusFilterOptions = [ { value: "all", label: "All Status" }, { value: "Active", label: "Active" }, - { value: "Suspended", label: "Suspended" }, - { value: "Pending", label: "Pending" }, + { value: "Completed", label: "Completed" }, { value: "Cancelled", label: "Cancelled" }, - { value: "Terminated", label: "Terminated" }, ]; const getStatusVariant = (status: string) => { switch (status) { case "Active": return "success" as const; - case "Suspended": - return "warning" as const; - case "Pending": + case "Completed": return "info" as const; case "Cancelled": - case "Terminated": return "neutral" as const; default: return "neutral" as const; @@ -239,7 +230,7 @@ export function SubscriptionsListContainer() { > {stats && ( -
+
@@ -256,25 +247,12 @@ export function SubscriptionsListContainer() {
- +
-
Suspended
-
{stats.suspended}
-
-
-
-
- -
-
- -
-
-
-
Pending
-
{stats.pending}
+
Completed
+
{stats.completed}