Enhance AuditService request logging by adding IP and connection details. Remove deprecated getSubscriptionStats method from WhmcsService to streamline subscription handling. Update WhmcsInvoiceService imports for better organization. Refactor payment method checks in WhmcsOrderService for clarity and efficiency. Improve error handling in WhmcsPaymentService and WhmcsSubscriptionService. Adjust subscription statistics in SubscriptionsService to reflect completed status instead of suspended and pending. Update frontend components to align with new subscription status structure.

This commit is contained in:
barsa 2025-10-03 11:29:59 +09:00
parent 0c3aa9ff4b
commit eded58ab93
23 changed files with 265 additions and 296 deletions

View File

@ -69,7 +69,12 @@ export class AuditService {
action: AuditAction, action: AuditAction,
userId?: string, userId?: string,
details?: Record<string, unknown> | string | number | boolean | null, details?: Record<string, unknown> | string | number | boolean | null,
request?: { headers?: Record<string, string | string[] | undefined> }, request?: {
headers?: Record<string, string | string[] | undefined>;
ip?: string;
connection?: { remoteAddress?: string };
socket?: { remoteAddress?: string };
},
success: boolean = true, success: boolean = true,
error?: string error?: string
): Promise<void> { ): Promise<void> {

View File

@ -1,11 +1,7 @@
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { Injectable, NotFoundException, Inject } from "@nestjs/common"; import { Injectable, NotFoundException, Inject } from "@nestjs/common";
import { Invoice, InvoiceList } from "@customer-portal/domain"; import { Invoice, InvoiceList, invoiceListSchema, invoiceSchema } from "@customer-portal/domain";
import {
invoiceListSchema,
invoiceSchema,
} from "@customer-portal/domain/validation/shared/entities";
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service"; import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
import { InvoiceTransformerService } from "../transformers/services/invoice-transformer.service"; import { InvoiceTransformerService } from "../transformers/services/invoice-transformer.service";
import { WhmcsCacheService } from "../cache/whmcs-cache.service"; import { WhmcsCacheService } from "../cache/whmcs-cache.service";

View File

@ -165,34 +165,17 @@ export class WhmcsOrderService {
*/ */
async hasPaymentMethod(clientId: number): Promise<boolean> { async hasPaymentMethod(clientId: number): Promise<boolean> {
try { try {
const response = (await this.connection.getPaymentMethods({ const response = await this.connection.getPaymentMethods({
clientid: clientId, clientid: clientId,
})) as unknown as Record<string, unknown>; });
if (response.result !== "success") { const methods = Array.isArray(response.paymethods) ? response.paymethods : [];
this.logger.warn("Failed to check payment methods", { const hasValidMethod = methods.length > 0;
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;
this.logger.log("Payment method check completed", { this.logger.log("Payment method check completed", {
clientId, clientId,
hasPaymentMethod: hasValidMethod, hasPaymentMethod: hasValidMethod,
methodCount, methodCount: methods.length,
totalResults,
}); });
return hasValidMethod; return hasValidMethod;

View File

@ -48,15 +48,6 @@ export class WhmcsPaymentService {
clientid: clientId, 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) const paymentMethodsArray: WhmcsPaymentMethod[] = Array.isArray(response.paymethods)
? response.paymethods ? response.paymethods
: []; : [];

View File

@ -58,7 +58,15 @@ export class WhmcsSubscriptionService {
const response = await this.connectionService.getClientsProducts(params); 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 productContainer = response.products?.product;
const products = Array.isArray(productContainer) const products = Array.isArray(productContainer)
? productContainer ? productContainer
@ -67,10 +75,9 @@ export class WhmcsSubscriptionService {
: []; : [];
this.logger.debug(`WHMCS GetClientsProducts response structure for client ${clientId}`, { this.logger.debug(`WHMCS GetClientsProducts response structure for client ${clientId}`, {
hasResponse: Boolean(response), totalresults: response.totalresults,
responseKeys: response ? Object.keys(response) : [], startnumber: response.startnumber,
hasProducts: Boolean(response.products), numreturned: response.numreturned,
productType: productContainer ? typeof productContainer : "undefined",
productCount: products.length, productCount: products.length,
}); });

View File

@ -34,8 +34,8 @@ export class SubscriptionTransformerService {
const subscription: Subscription = { const subscription: Subscription = {
id: Number(whmcsProduct.id), id: Number(whmcsProduct.id),
serviceId: Number(whmcsProduct.id), // In WHMCS, product ID is the service ID serviceId: Number(whmcsProduct.id), // In WHMCS, product ID is the service ID
productName: whmcsProduct.productname || whmcsProduct.name, productName: whmcsProduct.name || "",
domain: whmcsProduct.domain, domain: whmcsProduct.domain || undefined,
status: this.mapSubscriptionStatus(whmcsProduct.status), status: this.mapSubscriptionStatus(whmcsProduct.status),
cycle: billingCycle, cycle: billingCycle,
amount: this.getProductAmount(whmcsProduct), amount: this.getProductAmount(whmcsProduct),
@ -43,7 +43,7 @@ export class SubscriptionTransformerService {
currencySymbol: defaultCurrency.prefix || defaultCurrency.suffix, currencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
nextDue: DataUtils.formatDate(whmcsProduct.nextduedate), nextDue: DataUtils.formatDate(whmcsProduct.nextduedate),
registrationDate: DataUtils.formatDate(whmcsProduct.regdate) || new Date().toISOString(), 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 notes: undefined, // WhmcsProduct doesn't have notes field
}; };
@ -72,7 +72,7 @@ export class SubscriptionTransformerService {
error: message, error: message,
productId: whmcsProduct.id, productId: whmcsProduct.id,
status: whmcsProduct.status, status: whmcsProduct.status,
productName: whmcsProduct.name || whmcsProduct.productname, productName: whmcsProduct.name || whmcsProduct.name,
}); });
throw new Error(`Failed to transform subscription: ${message}`); throw new Error(`Failed to transform subscription: ${message}`);
} }
@ -117,40 +117,53 @@ export class SubscriptionTransformerService {
/** /**
* Normalize field name to camelCase * Normalize field name to camelCase
*/ */
private mapSubscriptionStatus(status: string): Subscription["status"] { private mapSubscriptionStatus(status: string | undefined): Subscription["status"] {
const allowed: Subscription["status"][] = [ if (typeof status !== "string") {
"Active", return "Cancelled";
"Suspended",
"Terminated",
"Cancelled",
"Pending",
"Completed",
];
if (allowed.includes(status as Subscription["status"])) {
return status as Subscription["status"];
} }
throw new Error(`Unsupported WHMCS subscription status: ${status}`); const normalized = status.trim().toLowerCase();
const statusMap: Record<string, Subscription["status"]> = {
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"] { private mapBillingCycle(cycle: string | undefined): Subscription["cycle"] {
const allowed: Subscription["cycle"][] = [ if (typeof cycle !== "string" || cycle.trim().length === 0) {
"Monthly", return "One-time";
"Quarterly",
"Semi-Annually",
"Annually",
"Biennially",
"Triennially",
"One-time",
"Free",
];
if (allowed.includes(cycle as Subscription["cycle"])) {
return cycle as Subscription["cycle"];
} }
throw new Error(`Unsupported WHMCS billing cycle: ${cycle}`); const normalized = cycle.trim().toLowerCase().replace(/[_\s-]+/g, " ");
const cycleMap: Record<string, Subscription["cycle"]> = {
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";
} }
/** /**

View File

@ -53,7 +53,6 @@ export interface WhmcsCustomField {
value: string; value: string;
// Legacy fields that may appear in some responses // Legacy fields that may appear in some responses
name?: string; name?: string;
translated_name?: string;
type?: string; type?: string;
} }
@ -117,74 +116,72 @@ export interface WhmcsInvoiceResponse extends WhmcsInvoice {
// Product/Service Types // Product/Service Types
export interface WhmcsProductsResponse { export interface WhmcsProductsResponse {
products: { result: "success" | "error";
product: WhmcsProduct | WhmcsProduct[]; message?: string;
}; clientid?: number | string;
totalresults?: number; serviceid?: number | string | null;
pid?: number | string | null;
domain?: string | null;
totalresults?: number | string;
startnumber?: number;
numreturned?: number; numreturned?: number;
products?: {
product?: WhmcsProduct | WhmcsProduct[];
};
} }
export interface WhmcsProduct { export interface WhmcsProduct {
id: number; id: number | string;
clientid: number; qty?: string;
orderid: number; clientid?: number | string;
pid: number; orderid?: number | string;
regdate: string; ordernumber?: string;
name: string; pid?: number | string;
regdate?: string;
name?: string;
translated_name?: string; translated_name?: string;
groupname?: string; groupname?: string;
productname?: string;
translated_groupname?: string; translated_groupname?: string;
domain: string; domain?: string;
dedicatedip?: string; dedicatedip?: string;
serverid?: number; serverid?: number | string;
servername?: string; servername?: string;
serverip?: string; serverip?: string;
serverhostname?: string; serverhostname?: string;
suspensionreason?: string; suspensionreason?: string;
promoid?: number; firstpaymentamount?: string;
producttype?: string; recurringamount?: string;
modulename?: string; paymentmethod?: string;
billingcycle: paymentmethodname?: string;
| "Monthly" billingcycle?: string;
| "Quarterly" nextduedate?: string;
| "Semi-Annually" status?: string;
| "Annually"
| "Biennially"
| "Triennially"
| "One-time"
| "Free";
nextduedate: string;
status: "Active" | "Suspended" | "Terminated" | "Cancelled" | "Pending" | "Completed";
username?: string; username?: string;
password?: string; password?: string;
subscriptionid?: string; subscriptionid?: string;
promotype?: string; promoid?: string;
promocode?: string; overideautosuspend?: string;
promodescription?: string;
promovalue?: string;
packageid?: number;
packagename?: string;
configoptions?: Record<string, unknown>;
customfields?: WhmcsCustomField[];
firstpaymentamount: string;
recurringamount: string;
paymentmethod: string;
paymentmethodname?: string;
currencycode?: string;
currencyprefix?: string;
currencysuffix?: string;
overideautosuspend?: boolean;
overidesuspenduntil?: string; overidesuspenduntil?: string;
ns1?: string; ns1?: string;
ns2?: string; ns2?: string;
assignedips?: string; assignedips?: string;
disk?: string; notes?: string;
disklimit?: string;
diskusage?: string; diskusage?: string;
bwlimit?: string; disklimit?: string;
bwusage?: string; bwusage?: string;
bwlimit?: string;
lastupdate?: string; lastupdate?: string;
customfields?: {
customfield?: WhmcsCustomField[];
};
configoptions?: {
configoption?: Array<{
id?: number | string;
option?: string;
type?: string;
value?: string;
}>;
};
} }
// SSO Token Types // SSO Token Types
@ -313,8 +310,7 @@ export interface WhmcsPaymentMethod {
} }
export interface WhmcsPayMethodsResponse { export interface WhmcsPayMethodsResponse {
result: "success" | "error"; clientid: number | string;
clientid?: number | string;
paymethods?: WhmcsPaymentMethod[]; paymethods?: WhmcsPaymentMethod[];
message?: string; message?: string;
} }
@ -383,7 +379,7 @@ export interface WhmcsCreateInvoiceResponse {
// UpdateInvoice API Types // UpdateInvoice API Types
export interface WhmcsUpdateInvoiceParams { export interface WhmcsUpdateInvoiceParams {
invoiceid: number; invoiceid: number;
status?: "Draft" | "Paid" | "Unpaid" | "Cancelled" | "Refunded" | "Collections" | "Overdue"; status?: "Draft" | "Paid" | "Unpaid" | "Cancelled" | "Refunded" | "Collections" | "Overdue";
duedate?: string; // YYYY-MM-DD format duedate?: string; // YYYY-MM-DD format
notes?: string; notes?: string;
[key: string]: unknown; [key: string]: unknown;

View File

@ -114,42 +114,6 @@ export class WhmcsService {
return this.subscriptionService.invalidateSubscriptionCache(userId, subscriptionId); 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) // CLIENT OPERATIONS (delegate to ClientService)
// ========================================== // ==========================================

View File

@ -295,7 +295,7 @@ export class AuthFacade {
} }
} }
async logout(userId: string, token?: string, _request?: Request): Promise<void> { async logout(userId: string, token?: string, request?: Request): Promise<void> {
if (token) { if (token) {
await this.tokenBlacklistService.blacklistToken(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 // Helper methods

View File

@ -41,10 +41,7 @@ export class AuthRateLimitService {
const signupLimit = this.configService.get<number>("SIGNUP_RATE_LIMIT_LIMIT", 5); const signupLimit = this.configService.get<number>("SIGNUP_RATE_LIMIT_LIMIT", 5);
const signupTtlMs = this.configService.get<number>("SIGNUP_RATE_LIMIT_TTL", 900000); const signupTtlMs = this.configService.get<number>("SIGNUP_RATE_LIMIT_TTL", 900000);
const passwordResetLimit = this.configService.get<number>( const passwordResetLimit = this.configService.get<number>("PASSWORD_RESET_RATE_LIMIT_LIMIT", 5);
"PASSWORD_RESET_RATE_LIMIT_LIMIT",
5
);
const passwordResetTtlMs = this.configService.get<number>( const passwordResetTtlMs = this.configService.get<number>(
"PASSWORD_RESET_RATE_LIMIT_TTL", "PASSWORD_RESET_RATE_LIMIT_TTL",
900000 900000
@ -53,10 +50,7 @@ export class AuthRateLimitService {
const refreshLimit = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_LIMIT", 10); const refreshLimit = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_LIMIT", 10);
const refreshTtlMs = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_TTL", 300000); const refreshTtlMs = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_TTL", 300000);
this.loginCaptchaThreshold = this.configService.get<number>( this.loginCaptchaThreshold = this.configService.get<number>("LOGIN_CAPTCHA_AFTER_ATTEMPTS", 3);
"LOGIN_CAPTCHA_AFTER_ATTEMPTS",
3
);
this.captchaAlwaysOn = this.configService.get("AUTH_CAPTCHA_ALWAYS_ON", "false") === "true"; this.captchaAlwaysOn = this.configService.get("AUTH_CAPTCHA_ALWAYS_ON", "false") === "true";
this.loginLimiter = this.createLimiter("auth-login", loginLimit, loginTtlMs); this.loginLimiter = this.createLimiter("auth-login", loginLimit, loginTtlMs);
@ -188,11 +182,7 @@ export class AuthRateLimitService {
} }
} }
private async deleteKey( private async deleteKey(limiter: RateLimiterRedis, key: string, context: string): Promise<void> {
limiter: RateLimiterRedis,
key: string,
context: string
): Promise<void> {
try { try {
await limiter.delete(key); await limiter.delete(key);
} catch (error: unknown) { } catch (error: unknown) {

View File

@ -78,7 +78,7 @@ export class TokenMigrationService {
stats.errors++; stats.errors++;
this.logger.error("Token migration failed", { this.logger.error("Token migration failed", {
error: error instanceof Error ? error.message : String(error), error: getErrorMessage(error),
stats, stats,
}); });
@ -106,7 +106,7 @@ export class TokenMigrationService {
stats.errors++; stats.errors++;
this.logger.error("Failed to migrate family", { this.logger.error("Failed to migrate family", {
key, key,
error: error instanceof Error ? error.message : String(error), error: getErrorMessage(error),
}); });
} }
} }
@ -188,7 +188,7 @@ export class TokenMigrationService {
stats.errors++; stats.errors++;
this.logger.error("Failed to migrate token", { this.logger.error("Failed to migrate token", {
key, key,
error: error instanceof Error ? error.message : String(error), error: getErrorMessage(error),
}); });
} }
} }

View File

@ -48,16 +48,18 @@ export class WhmcsLinkWorkflowService {
try { try {
clientDetails = await this.whmcsService.getClientDetailsByEmail(email); clientDetails = await this.whmcsService.getClientDetailsByEmail(email);
} catch (error) { } catch (error) {
this.logger.error("WHMCS client lookup failed", { this.logger.error("WHMCS client lookup failed", {
error: getErrorMessage(error), 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 // Provide more specific error messages based on the error type
if (error instanceof Error && error.message.includes('not found')) { 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(
"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."); throw new UnauthorizedException("Unable to verify account. Please try again later.");
} }
@ -79,18 +81,24 @@ export class WhmcsLinkWorkflowService {
} }
} catch (error) { } catch (error) {
if (error instanceof UnauthorizedException) throw error; if (error instanceof UnauthorizedException) throw error;
const errorMessage = getErrorMessage(error); const errorMessage = getErrorMessage(error);
this.logger.error("WHMCS credential validation failed", { error: errorMessage }); this.logger.error("WHMCS credential validation failed", { error: errorMessage });
// Check if this is a WHMCS authentication error and provide user-friendly message // Check if this is a WHMCS authentication error and provide user-friendly message
if (errorMessage.toLowerCase().includes('email or password invalid') || const normalizedMessage = errorMessage.toLowerCase();
errorMessage.toLowerCase().includes('invalid email or password') || const authErrorPhrases = [
errorMessage.toLowerCase().includes('authentication failed') || "email or password invalid",
errorMessage.toLowerCase().includes('login failed')) { "invalid email or password",
throw new UnauthorizedException("Invalid email or password. Please check your credentials and try again."); "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 // For other errors, provide generic message to avoid exposing system details
throw new UnauthorizedException("Unable to verify credentials. Please try again later."); throw new UnauthorizedException("Unable to verify credentials. Please try again later.");
} }

View File

@ -47,7 +47,9 @@ import {
} from "@customer-portal/domain"; } from "@customer-portal/domain";
import type { AuthTokens } from "@customer-portal/domain"; import type { AuthTokens } from "@customer-portal/domain";
type RequestWithCookies = Request & { cookies?: Record<string, string | undefined> }; type RequestWithCookies = Request & {
cookies: Record<string, any>;
};
const resolveAuthorizationHeader = (req: RequestWithCookies): string | undefined => { const resolveAuthorizationHeader = (req: RequestWithCookies): string | undefined => {
const rawHeader = req.headers?.authorization; const rawHeader = req.headers?.authorization;
@ -197,9 +199,11 @@ export class AuthController {
@Res({ passthrough: true }) res: Response @Res({ passthrough: true }) res: Response
) { ) {
const refreshToken = body.refreshToken ?? req.cookies?.refresh_token; 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, { const result = await this.authFacade.refreshTokens(refreshToken, {
deviceId: body.deviceId, deviceId: body.deviceId,
userAgent: req.headers["user-agent"], userAgent,
}); });
this.setAuthCookies(res, result.tokens); this.setAuthCookies(res, result.tokens);
return result; return result;

View File

@ -15,7 +15,9 @@ import { TokenBlacklistService } from "../../../infra/token/token-blacklist.serv
import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator"; import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator";
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
type RequestWithCookies = Request & { cookies?: Record<string, string | undefined> }; type RequestWithCookies = Request & {
cookies: Record<string, any>;
};
type RequestWithRoute = RequestWithCookies & { type RequestWithRoute = RequestWithCookies & {
method: string; method: string;
url: string; url: string;
@ -44,8 +46,8 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
} }
override async canActivate(context: ExecutionContext): Promise<boolean> { override async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<RequestWithRoute>(); const request = this.getRequest(context);
const route = `${request.method} ${request.route?.path ?? request.url}`; const route = `${request.method} ${request.url}`;
// Check if the route is marked as public // Check if the route is marked as public
const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [ const isPublic = this.reflector.getAllAndOverride<boolean>(IS_PUBLIC_KEY, [
@ -84,4 +86,24 @@ export class GlobalAuthGuard extends AuthGuard("jwt") implements CanActivate {
throw error; throw error;
} }
} }
override getRequest(context: ExecutionContext): RequestWithRoute {
const rawRequest = context.switchToHttp().getRequest<unknown>();
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<RequestWithRoute>;
return (
typeof candidate.method === "string" &&
typeof candidate.url === "string" &&
(!candidate.route || typeof candidate.route === "object")
);
}
} }

View File

@ -5,7 +5,7 @@ import {
CallHandler, CallHandler,
UnauthorizedException, UnauthorizedException,
} from "@nestjs/common"; } from "@nestjs/common";
import { Observable, throwError } from "rxjs"; import { Observable, defer } from "rxjs";
import { tap, catchError } from "rxjs/operators"; import { tap, catchError } from "rxjs/operators";
import { FailedLoginThrottleGuard } from "@bff/modules/auth/presentation/http/guards/failed-login-throttle.guard"; import { FailedLoginThrottleGuard } from "@bff/modules/auth/presentation/http/guards/failed-login-throttle.guard";
import type { Request } from "express"; import type { Request } from "express";
@ -14,27 +14,74 @@ import type { Request } from "express";
export class LoginResultInterceptor implements NestInterceptor { export class LoginResultInterceptor implements NestInterceptor {
constructor(private readonly failedLoginGuard: FailedLoginThrottleGuard) {} constructor(private readonly failedLoginGuard: FailedLoginThrottleGuard) {}
intercept(context: ExecutionContext, next: CallHandler): Observable<any> { intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const request = context.switchToHttp().getRequest<Request>(); const rawRequest = context.switchToHttp().getRequest<unknown>();
if (!this.isExpressRequest(rawRequest)) {
throw new UnauthorizedException("Invalid request context");
}
const request: Request = rawRequest;
return next.handle().pipe( return next.handle().pipe(
tap(async () => { tap(() => {
await this.failedLoginGuard.handleLoginResult(request, true); void this.failedLoginGuard.handleLoginResult(request, true);
}), }),
catchError(async error => { catchError(error =>
const message = typeof error?.message === "string" ? error.message.toLowerCase() : ""; defer(async () => {
const isAuthError = const message = this.extractErrorMessage(error).toLowerCase();
error instanceof UnauthorizedException || const status = this.extractStatusCode(error);
error?.status === 401 || const isAuthError =
message.includes("invalid") || error instanceof UnauthorizedException ||
message.includes("unauthorized"); status === 401 ||
message.includes("invalid") ||
message.includes("unauthorized");
if (isAuthError) { if (isAuthError) {
await this.failedLoginGuard.handleLoginResult(request, false); 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<Request> & {
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;
}
} }

View File

@ -1,2 +1 @@
export * from "./http/auth.controller"; export * from "./http/auth.controller";

View File

@ -8,11 +8,12 @@ import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util";
import type { Request } from "express"; import type { Request } from "express";
const cookieExtractor = (req: Request): string | null => { const cookieExtractor = (req: Request): string | null => {
const cookieToken = req?.cookies?.access_token; const cookieSource: unknown = Reflect.get(req, "cookies");
if (typeof cookieToken === "string" && cookieToken.length > 0) { if (!cookieSource || typeof cookieSource !== "object") {
return cookieToken; return null;
} }
return null; const token = Reflect.get(cookieSource, "access_token") as unknown;
return typeof token === "string" && token.length > 0 ? token : null;
}; };
@Injectable() @Injectable()

View File

@ -2,7 +2,7 @@ import { Injectable, BadRequestException, NotFoundException, Inject } from "@nes
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { OrderPricebookService } from "./order-pricebook.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"; import { createOrderRequestSchema } from "@customer-portal/domain";
/** /**

View File

@ -62,9 +62,8 @@ export class SubscriptionsController {
async getSubscriptionStats(@Request() req: RequestWithUser): Promise<{ async getSubscriptionStats(@Request() req: RequestWithUser): Promise<{
total: number; total: number;
active: number; active: number;
suspended: number; completed: number;
cancelled: number; cancelled: number;
pending: number;
}> { }> {
return this.subscriptionsService.getSubscriptionStats(req.user.id); return this.subscriptionsService.getSubscriptionStats(req.user.id);
} }

View File

@ -7,10 +7,7 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { z } from "zod"; import { z } from "zod";
import { subscriptionSchema } from "@customer-portal/domain"; import { subscriptionSchema } from "@customer-portal/domain";
import type { import type { WhmcsProduct } from "@bff/integrations/whmcs/types/whmcs-api.types";
WhmcsProduct,
WhmcsProductsResponse,
} from "@bff/integrations/whmcs/types/whmcs-api.types";
export interface GetSubscriptionsOptions { export interface GetSubscriptionsOptions {
status?: string; status?: string;
@ -194,13 +191,8 @@ export class SubscriptionsService {
async getSubscriptionStats(userId: string): Promise<{ async getSubscriptionStats(userId: string): Promise<{
total: number; total: number;
active: number; active: number;
suspended: number;
cancelled: number;
pending: number;
completed: number; completed: number;
totalMonthlyRevenue: number; cancelled: number;
activeMonthlyRevenue: number;
currency: string;
}> { }> {
try { try {
// Get WHMCS client ID from user mapping // Get WHMCS client ID from user mapping
@ -209,39 +201,18 @@ export class SubscriptionsService {
throw new NotFoundException("WHMCS client mapping not found"); throw new NotFoundException("WHMCS client mapping not found");
} }
// Get basic stats from WHMCS service // Get all subscriptions and aggregate customer-facing stats only
const basicStats = await this.whmcsService.getSubscriptionStats(
mapping.whmcsClientId,
userId
);
// Get all subscriptions for financial calculations
const subscriptionList = await this.getSubscriptions(userId); const subscriptionList = await this.getSubscriptions(userId);
const subscriptions: Subscription[] = subscriptionList.subscriptions; 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 = { const stats = {
...basicStats, total: subscriptions.length,
active: subscriptions.filter(s => s.status === "Active").length,
completed: subscriptions.filter(s => s.status === "Completed").length, completed: subscriptions.filter(s => s.status === "Completed").length,
totalMonthlyRevenue, cancelled: subscriptions.filter(s => s.status === "Cancelled").length,
activeMonthlyRevenue,
currency: subscriptions[0]?.currency || "JPY",
}; };
this.logger.log(`Generated subscription stats for user ${userId}`, { this.logger.log(`Generated subscription stats for user ${userId}`, stats);
...stats,
// Don't log revenue amounts for security
totalMonthlyRevenue: "[CALCULATED]",
activeMonthlyRevenue: "[CALCULATED]",
});
return stats; return stats;
} catch (error) { } catch (error) {
@ -511,7 +482,7 @@ export class SubscriptionsService {
return false; return false;
} }
const productsResponse: WhmcsProductsResponse = await this.whmcsService.getClientsProducts({ const productsResponse = await this.whmcsService.getClientsProducts({
clientid: mapping.whmcsClientId, clientid: mapping.whmcsClientId,
}); });
const productContainer = productsResponse.products?.product; const productContainer = productsResponse.products?.product;

View File

@ -15,11 +15,7 @@ declare module "rate-limiter-flexible" {
remainingPoints: number; remainingPoints: number;
consumedPoints: number; consumedPoints: number;
msBeforeNext: number; msBeforeNext: number;
constructor(data: { constructor(data: { remainingPoints: number; consumedPoints: number; msBeforeNext: number });
remainingPoints: number;
consumedPoints: number;
msBeforeNext: number;
});
} }
export class RateLimiterRedis { export class RateLimiterRedis {

View File

@ -20,9 +20,8 @@ const emptySubscriptionList: SubscriptionList = {
const emptyStats = { const emptyStats = {
total: 0, total: 0,
active: 0, active: 0,
suspended: 0, completed: 0,
cancelled: 0, cancelled: 0,
pending: 0,
}; };
const emptyInvoiceList: InvoiceList = { const emptyInvoiceList: InvoiceList = {

View File

@ -16,7 +16,6 @@ import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { import {
ServerIcon, ServerIcon,
CheckCircleIcon, CheckCircleIcon,
ExclamationTriangleIcon,
ClockIcon, ClockIcon,
XCircleIcon, XCircleIcon,
CalendarIcon, CalendarIcon,
@ -59,12 +58,9 @@ export function SubscriptionsListContainer() {
switch (status) { switch (status) {
case "Active": case "Active":
return <CheckCircleIcon className="h-5 w-5 text-green-500" />; return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
case "Suspended": case "Completed":
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />; return <CheckCircleIcon className="h-5 w-5 text-blue-500" />;
case "Pending":
return <ClockIcon className="h-5 w-5 text-blue-500" />;
case "Cancelled": case "Cancelled":
case "Terminated":
return <XCircleIcon className="h-5 w-5 text-gray-500" />; return <XCircleIcon className="h-5 w-5 text-gray-500" />;
default: default:
return <ClockIcon className="h-5 w-5 text-gray-500" />; return <ClockIcon className="h-5 w-5 text-gray-500" />;
@ -82,22 +78,17 @@ export function SubscriptionsListContainer() {
const statusFilterOptions = [ const statusFilterOptions = [
{ value: "all", label: "All Status" }, { value: "all", label: "All Status" },
{ value: "Active", label: "Active" }, { value: "Active", label: "Active" },
{ value: "Suspended", label: "Suspended" }, { value: "Completed", label: "Completed" },
{ value: "Pending", label: "Pending" },
{ value: "Cancelled", label: "Cancelled" }, { value: "Cancelled", label: "Cancelled" },
{ value: "Terminated", label: "Terminated" },
]; ];
const getStatusVariant = (status: string) => { const getStatusVariant = (status: string) => {
switch (status) { switch (status) {
case "Active": case "Active":
return "success" as const; return "success" as const;
case "Suspended": case "Completed":
return "warning" as const;
case "Pending":
return "info" as const; return "info" as const;
case "Cancelled": case "Cancelled":
case "Terminated":
return "neutral" as const; return "neutral" as const;
default: default:
return "neutral" as const; return "neutral" as const;
@ -239,7 +230,7 @@ export function SubscriptionsListContainer() {
> >
<ErrorBoundary> <ErrorBoundary>
{stats && ( {stats && (
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4 mb-6">
<SubCard> <SubCard>
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
@ -256,25 +247,12 @@ export function SubscriptionsListContainer() {
<SubCard> <SubCard>
<div className="flex items-center"> <div className="flex items-center">
<div className="flex-shrink-0"> <div className="flex-shrink-0">
<ExclamationTriangleIcon className="h-8 w-8 text-yellow-600" /> <CheckCircleIcon className="h-8 w-8 text-blue-600" />
</div> </div>
<div className="ml-5 w-0 flex-1"> <div className="ml-5 w-0 flex-1">
<dl> <dl>
<dt className="text-sm font-medium text-gray-500 truncate">Suspended</dt> <dt className="text-sm font-medium text-gray-500 truncate">Completed</dt>
<dd className="text-lg font-medium text-gray-900">{stats.suspended}</dd> <dd className="text-lg font-medium text-gray-900">{stats.completed}</dd>
</dl>
</div>
</div>
</SubCard>
<SubCard>
<div className="flex items-center">
<div className="flex-shrink-0">
<ClockIcon className="h-8 w-8 text-blue-600" />
</div>
<div className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Pending</dt>
<dd className="text-lg font-medium text-gray-900">{stats.pending}</dd>
</dl> </dl>
</div> </div>
</div> </div>