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,
userId?: string,
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,
error?: string
): Promise<void> {

View File

@ -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";

View File

@ -165,34 +165,17 @@ export class WhmcsOrderService {
*/
async hasPaymentMethod(clientId: number): Promise<boolean> {
try {
const response = (await this.connection.getPaymentMethods({
const response = await this.connection.getPaymentMethods({
clientid: clientId,
})) as unknown as Record<string, unknown>;
});
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;

View File

@ -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
: [];

View File

@ -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,
});

View File

@ -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<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"] {
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<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;
// 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<string, unknown>;
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;

View File

@ -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)
// ==========================================

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) {
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

View File

@ -41,10 +41,7 @@ export class AuthRateLimitService {
const signupLimit = this.configService.get<number>("SIGNUP_RATE_LIMIT_LIMIT", 5);
const signupTtlMs = this.configService.get<number>("SIGNUP_RATE_LIMIT_TTL", 900000);
const passwordResetLimit = this.configService.get<number>(
"PASSWORD_RESET_RATE_LIMIT_LIMIT",
5
);
const passwordResetLimit = this.configService.get<number>("PASSWORD_RESET_RATE_LIMIT_LIMIT", 5);
const passwordResetTtlMs = this.configService.get<number>(
"PASSWORD_RESET_RATE_LIMIT_TTL",
900000
@ -53,10 +50,7 @@ export class AuthRateLimitService {
const refreshLimit = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_LIMIT", 10);
const refreshTtlMs = this.configService.get<number>("AUTH_REFRESH_RATE_LIMIT_TTL", 300000);
this.loginCaptchaThreshold = this.configService.get<number>(
"LOGIN_CAPTCHA_AFTER_ATTEMPTS",
3
);
this.loginCaptchaThreshold = this.configService.get<number>("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<void> {
private async deleteKey(limiter: RateLimiterRedis, key: string, context: string): Promise<void> {
try {
await limiter.delete(key);
} catch (error: unknown) {

View File

@ -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),
});
}
}

View File

@ -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.");
}

View File

@ -47,7 +47,9 @@ import {
} 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 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;

View File

@ -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<string, string | undefined> };
type RequestWithCookies = Request & {
cookies: Record<string, any>;
};
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<boolean> {
const request = context.switchToHttp().getRequest<RequestWithRoute>();
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<boolean>(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<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,
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<any> {
const request = context.switchToHttp().getRequest<Request>();
intercept(context: ExecutionContext, next: CallHandler): Observable<unknown> {
const rawRequest = context.switchToHttp().getRequest<unknown>();
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<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";

View File

@ -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()

View File

@ -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";
/**

View File

@ -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);
}

View File

@ -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;

View File

@ -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 {

View File

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

View File

@ -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 <CheckCircleIcon className="h-5 w-5 text-green-500" />;
case "Suspended":
return <ExclamationTriangleIcon className="h-5 w-5 text-yellow-500" />;
case "Pending":
return <ClockIcon className="h-5 w-5 text-blue-500" />;
case "Completed":
return <CheckCircleIcon className="h-5 w-5 text-blue-500" />;
case "Cancelled":
case "Terminated":
return <XCircleIcon className="h-5 w-5 text-gray-500" />;
default:
return <ClockIcon className="h-5 w-5 text-gray-500" />;
@ -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() {
>
<ErrorBoundary>
{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>
<div className="flex items-center">
<div className="flex-shrink-0">
@ -256,25 +247,12 @@ export function SubscriptionsListContainer() {
<SubCard>
<div className="flex items-center">
<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 className="ml-5 w-0 flex-1">
<dl>
<dt className="text-sm font-medium text-gray-500 truncate">Suspended</dt>
<dd className="text-lg font-medium text-gray-900">{stats.suspended}</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>
<dt className="text-sm font-medium text-gray-500 truncate">Completed</dt>
<dd className="text-lg font-medium text-gray-900">{stats.completed}</dd>
</dl>
</div>
</div>