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:
parent
0c3aa9ff4b
commit
eded58ab93
@ -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> {
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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
|
||||
: [];
|
||||
|
||||
@ -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,
|
||||
});
|
||||
|
||||
|
||||
@ -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";
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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)
|
||||
// ==========================================
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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.");
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,2 +1 @@
|
||||
export * from "./http/auth.controller";
|
||||
|
||||
|
||||
@ -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()
|
||||
|
||||
@ -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";
|
||||
|
||||
/**
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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 {
|
||||
|
||||
@ -20,9 +20,8 @@ const emptySubscriptionList: SubscriptionList = {
|
||||
const emptyStats = {
|
||||
total: 0,
|
||||
active: 0,
|
||||
suspended: 0,
|
||||
completed: 0,
|
||||
cancelled: 0,
|
||||
pending: 0,
|
||||
};
|
||||
|
||||
const emptyInvoiceList: InvoiceList = {
|
||||
|
||||
@ -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>
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user