diff --git a/apps/bff/src/app/bootstrap.ts b/apps/bff/src/app/bootstrap.ts index 536d8e00..68dcc9ff 100644 --- a/apps/bff/src/app/bootstrap.ts +++ b/apps/bff/src/app/bootstrap.ts @@ -119,7 +119,7 @@ export async function bootstrap(): Promise { // Global exception filters app.useGlobalFilters( new AuthErrorFilter(app.get(Logger)), // Handle auth errors first - new GlobalExceptionFilter(app.get(Logger), configService, app.get(SecureErrorMapperService)) // Handle all other errors + new GlobalExceptionFilter(app.get(Logger), app.get(SecureErrorMapperService)) // Handle all other errors ); // Global authentication guard will be registered via APP_GUARD provider in AuthModule diff --git a/apps/bff/src/core/database/services/distributed-transaction.service.ts b/apps/bff/src/core/database/services/distributed-transaction.service.ts index 817adb55..7a651019 100644 --- a/apps/bff/src/core/database/services/distributed-transaction.service.ts +++ b/apps/bff/src/core/database/services/distributed-transaction.service.ts @@ -116,7 +116,7 @@ export class DistributedTransactionService { timeout, }); - const stepResults: StepResultMap = {}; + const stepResults: StepResultMap = {} as StepResultMap; const executedSteps: string[] = []; const failedSteps: string[] = []; @@ -133,7 +133,8 @@ export class DistributedTransactionService { const result = await this.executeStepWithTimeout(step, timeout); const stepDuration = Date.now() - stepStartTime; - stepResults[step.id] = result; + const key = step.id as keyof StepResultMap; + Reflect.set(stepResults, key, result); executedSteps.push(step.id); this.logger.debug(`Step completed: ${step.id} [${transactionId}]`, { @@ -197,12 +198,7 @@ export class DistributedTransactionService { }); // Execute rollbacks for completed steps - const rollbacksExecuted = await this.executeRollbacks( - steps, - executedSteps, - stepResults, - transactionId - ); + const rollbacksExecuted = await this.executeRollbacks(steps, executedSteps, transactionId); return { success: false, @@ -314,12 +310,8 @@ export class DistributedTransactionService { if (!dbTransactionResult.success) { // Rollback external operations - await this.executeRollbacks( - externalSteps, - Object.keys(externalResult.stepResults), - externalResult.stepResults, - transactionId - ); + const executedExternalSteps = Object.keys(externalResult.stepResults) as string[]; + await this.executeRollbacks(externalSteps, executedExternalSteps, transactionId); throw new Error(dbTransactionResult.error || "Database transaction failed"); } @@ -339,7 +331,7 @@ export class DistributedTransactionService { duration, stepsExecuted: externalResult?.stepsExecuted || 0, stepsRolledBack: 0, - stepResults: (externalResult?.stepResults ?? {}) as TStepResults, + stepResults: (externalResult?.stepResults ?? ({} as StepResultMap)) as TStepResults, failedSteps: externalResult?.failedSteps || [], }; } catch (error) { @@ -357,7 +349,7 @@ export class DistributedTransactionService { duration, stepsExecuted: 0, stepsRolledBack: 0, - stepResults: {}, + stepResults: {} as TStepResults, failedSteps: [], }; } @@ -379,8 +371,7 @@ export class DistributedTransactionService { private async executeRollbacks( steps: TSteps, - executedSteps: string[], - _stepResults: Partial>, + executedSteps: readonly string[], transactionId: string ): Promise { this.logger.warn(`Executing rollbacks for ${executedSteps.length} steps [${transactionId}]`); diff --git a/apps/bff/src/core/database/services/transaction.service.ts b/apps/bff/src/core/database/services/transaction.service.ts index 50ab8101..6f601446 100644 --- a/apps/bff/src/core/database/services/transaction.service.ts +++ b/apps/bff/src/core/database/services/transaction.service.ts @@ -255,7 +255,9 @@ export class TransactionService { ); } - private enhanceContext(context: TransactionContext): TransactionContext { + private enhanceContext( + context: TransactionContext + ): TransactionContext & TransactionContextHelpers { const helpers: TransactionContextHelpers = { addOperation: (description: string) => { context.operations.push(`${new Date().toISOString()}: ${description}`); diff --git a/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts b/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts index 8869653e..ef4a6cb7 100644 --- a/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts +++ b/apps/bff/src/core/queue/services/salesforce-request-queue.service.ts @@ -1,7 +1,6 @@ import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; -import PQueue from "p-queue"; export interface SalesforceQueueMetrics { totalRequests: number; completedRequests: number; @@ -39,10 +38,29 @@ export interface SalesforceRequestOptions { * - Timeout: 10 minutes per request * - Rate limiting: Conservative 120 requests per minute (2 RPS) */ +type PQueueCtor = new (options: { + concurrency?: number; + interval?: number; + intervalCap?: number; + timeout?: number; + throwOnTimeout?: boolean; + carryoverConcurrencyCount?: boolean; +}) => PQueueInstance; + +interface PQueueInstance { + add(fn: () => Promise, options?: { priority?: number }): Promise; + clear(): void; + onIdle(): Promise; + on(event: "add" | "next" | "idle" | "error", listener: (...args: unknown[]) => void): void; + size: number; + pending: number; +} + @Injectable() export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDestroy { - private standardQueue: PQueue | null = null; - private longRunningQueue: PQueue | null = null; + private pQueueCtor: PQueueCtor | null = null; + private standardQueue: PQueueInstance | null = null; + private longRunningQueue: PQueueInstance | null = null; private readonly metrics: SalesforceQueueMetrics = { totalRequests: 0, completedRequests: 0, @@ -66,8 +84,20 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest this.dailyUsageResetTime = this.getNextDayReset(); } - private ensureQueuesInitialized(): { standardQueue: PQueue; longRunningQueue: PQueue } { + private async loadPQueue(): Promise { + if (!this.pQueueCtor) { + const module = await import("p-queue"); + this.pQueueCtor = module.default as PQueueCtor; + } + return this.pQueueCtor; + } + + private async ensureQueuesInitialized(): Promise<{ + standardQueue: PQueueInstance; + longRunningQueue: PQueueInstance; + }> { if (!this.standardQueue || !this.longRunningQueue) { + const PQueue = await this.loadPQueue(); const concurrency = this.configService.get("SF_QUEUE_CONCURRENCY", 15); const longRunningConcurrency = this.configService.get( "SF_QUEUE_LONG_RUNNING_CONCURRENCY", @@ -109,14 +139,11 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest throw new Error("Failed to initialize Salesforce queues"); } - return { - standardQueue, - longRunningQueue, - }; + return { standardQueue, longRunningQueue }; } - onModuleInit() { - this.ensureQueuesInitialized(); + async onModuleInit() { + await this.ensureQueuesInitialized(); const concurrency = this.configService.get("SF_QUEUE_CONCURRENCY", 15); const longRunningConcurrency = this.configService.get( "SF_QUEUE_LONG_RUNNING_CONCURRENCY", @@ -171,7 +198,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest requestFn: () => Promise, options: SalesforceRequestOptions = {} ): Promise { - const { standardQueue, longRunningQueue } = this.ensureQueuesInitialized(); + const { standardQueue, longRunningQueue } = await this.ensureQueuesInitialized(); // Check daily API usage this.checkDailyUsage(); @@ -194,7 +221,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest }); try { - const result = await queue.add( + const result = (await queue.add( async () => { const waitTime = Date.now() - startTime; this.recordWaitTime(waitTime); @@ -247,7 +274,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest { priority: options.priority ?? 0, } - ); + )) as T; return result; } catch (error) { @@ -341,7 +368,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest * Clear the queues (emergency use only) */ async clearQueues(): Promise { - const { standardQueue, longRunningQueue } = this.ensureQueuesInitialized(); + const { standardQueue, longRunningQueue } = await this.ensureQueuesInitialized(); this.logger.warn("Clearing Salesforce request queues", { standardQueueSize: standardQueue.size, @@ -367,7 +394,8 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - return await requestFn(); + const result = await requestFn(); + return result; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); @@ -446,7 +474,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest this.logger.debug("Salesforce standard queue is idle"); this.updateQueueMetrics(); }); - this.standardQueue.on("error", (error: Error) => { + this.standardQueue.on("error", (error: unknown) => { this.logger.error("Salesforce standard queue error", { error: error instanceof Error ? error.message : String(error), }); @@ -458,7 +486,7 @@ export class SalesforceRequestQueueService implements OnModuleInit, OnModuleDest this.logger.debug("Salesforce long-running queue is idle"); this.updateQueueMetrics(); }); - this.longRunningQueue.on("error", (error: Error) => { + this.longRunningQueue.on("error", (error: unknown) => { this.logger.error("Salesforce long-running queue error", { error: error instanceof Error ? error.message : String(error), }); diff --git a/apps/bff/src/core/queue/services/whmcs-request-queue.service.ts b/apps/bff/src/core/queue/services/whmcs-request-queue.service.ts index ab9ca77c..9e8f3b6c 100644 --- a/apps/bff/src/core/queue/services/whmcs-request-queue.service.ts +++ b/apps/bff/src/core/queue/services/whmcs-request-queue.service.ts @@ -1,7 +1,24 @@ import { Injectable, Inject, OnModuleInit, OnModuleDestroy } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { ConfigService } from "@nestjs/config"; -import PQueue from "p-queue"; + +type PQueueCtor = new (options: { + concurrency?: number; + interval?: number; + intervalCap?: number; + timeout?: number; + throwOnTimeout?: boolean; + carryoverConcurrencyCount?: boolean; +}) => PQueueInstance; + +interface PQueueInstance { + add(fn: () => Promise, options?: { priority?: number }): Promise; + clear(): void; + onIdle(): Promise; + on(event: "add" | "next" | "idle" | "error", listener: (...args: unknown[]) => void): void; + size: number; + pending: number; +} export interface WhmcsQueueMetrics { totalRequests: number; completedRequests: number; @@ -37,7 +54,8 @@ export interface WhmcsRequestOptions { */ @Injectable() export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { - private queue: PQueue | null = null; + private pQueueCtor: PQueueCtor | null = null; + private queue: PQueueInstance | null = null; private readonly metrics: WhmcsQueueMetrics = { totalRequests: 0, completedRequests: 0, @@ -57,8 +75,17 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { private readonly configService: ConfigService ) {} - private ensureQueueInitialized(): PQueue { + private async loadPQueue(): Promise { + if (!this.pQueueCtor) { + const module = await import("p-queue"); + this.pQueueCtor = module.default as PQueueCtor; + } + return this.pQueueCtor; + } + + private async ensureQueueInitialized(): Promise { if (!this.queue) { + const PQueue = await this.loadPQueue(); const concurrency = this.configService.get("WHMCS_QUEUE_CONCURRENCY", 15); const intervalCap = this.configService.get("WHMCS_QUEUE_INTERVAL_CAP", 300); const timeout = this.configService.get("WHMCS_QUEUE_TIMEOUT_MS", 30000); @@ -84,8 +111,8 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { return this.queue; } - onModuleInit() { - this.ensureQueueInitialized(); + async onModuleInit() { + await this.ensureQueueInitialized(); const concurrency = this.configService.get("WHMCS_QUEUE_CONCURRENCY", 15); const intervalCap = this.configService.get("WHMCS_QUEUE_INTERVAL_CAP", 300); const timeout = this.configService.get("WHMCS_QUEUE_TIMEOUT_MS", 30000); @@ -117,7 +144,7 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { * Execute a WHMCS API request through the queue */ async execute(requestFn: () => Promise, options: WhmcsRequestOptions = {}): Promise { - const queue = this.ensureQueueInitialized(); + const queue = await this.ensureQueueInitialized(); const startTime = Date.now(); const requestId = this.generateRequestId(); @@ -132,7 +159,7 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { }); try { - const result = await queue.add( + const result = (await queue.add( async () => { const waitTime = Date.now() - startTime; this.recordWaitTime(waitTime); @@ -174,7 +201,7 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { { priority: options.priority ?? 0, } - ); + )) as T; return result; } catch (error) { @@ -264,7 +291,8 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { for (let attempt = 1; attempt <= maxAttempts; attempt++) { try { - return await requestFn(); + const result = await requestFn(); + return result; } catch (error) { lastError = error instanceof Error ? error : new Error(String(error)); @@ -307,7 +335,7 @@ export class WhmcsRequestQueueService implements OnModuleInit, OnModuleDestroy { this.updateQueueMetrics(); }); - this.queue.on("error", (error: Error) => { + this.queue.on("error", (error: unknown) => { this.logger.error("WHMCS queue error", { error: error instanceof Error ? error.message : String(error), }); diff --git a/apps/bff/src/core/security/controllers/csrf.controller.ts b/apps/bff/src/core/security/controllers/csrf.controller.ts index 63d16df2..d0b91bed 100644 --- a/apps/bff/src/core/security/controllers/csrf.controller.ts +++ b/apps/bff/src/core/security/controllers/csrf.controller.ts @@ -6,7 +6,6 @@ import { CsrfService } from "../services/csrf.service"; type AuthenticatedRequest = Request & { user?: { id: string; sessionId?: string }; sessionID?: string; - cookies: Record; }; @Controller("security/csrf") diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index 32df45b8..5cc5f04a 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -1,7 +1,11 @@ import { getErrorMessage } from "@bff/core/utils/error.util"; import { Logger } from "nestjs-pino"; import { Injectable, NotFoundException, Inject } from "@nestjs/common"; -import { Invoice, InvoiceList, invoiceListSchema, invoiceSchema } from "@customer-portal/domain"; +import { Invoice, InvoiceList } from "@customer-portal/domain"; +import { + invoiceListSchema, + invoiceSchema, +} from "@customer-portal/domain/validation/shared/entities"; import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service"; import { InvoiceTransformerService } from "../transformers/services/invoice-transformer.service"; import { WhmcsCacheService } from "../cache/whmcs-cache.service"; @@ -62,7 +66,7 @@ export class WhmcsInvoiceService { const response = await this.connectionService.getInvoices(params); const transformed = this.transformInvoicesResponse(response, clientId, page, limit); - const result = invoiceListSchema.parse(transformed); + const result = invoiceListSchema.parse(transformed as unknown); // Cache the result await this.cacheService.setInvoicesList(userId, page, limit, status, result); @@ -205,8 +209,8 @@ export class WhmcsInvoiceService { for (const whmcsInvoice of response.invoices.invoice) { try { const transformed = this.invoiceTransformer.transformInvoice(whmcsInvoice); - const parsed = invoiceSchema.parse(transformed); - invoices.push(parsed as any); + const parsed = invoiceSchema.parse(transformed as unknown); + invoices.push(parsed); } catch (error) { this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, { error: getErrorMessage(error), @@ -312,14 +316,22 @@ export class WhmcsInvoiceService { | "Cancelled" | "Refunded" | "Collections" - | "Payment Pending"; + | "Payment Pending" + | "Overdue"; dueDate?: Date; notes?: string; }): Promise<{ success: boolean; message?: string }> { try { + let statusForUpdate: WhmcsUpdateInvoiceParams["status"]; + if (params.status === "Payment Pending") { + statusForUpdate = "Unpaid"; + } else { + statusForUpdate = params.status; + } + const whmcsParams: WhmcsUpdateInvoiceParams = { invoiceid: params.invoiceId, - status: params.status, + status: statusForUpdate, duedate: params.dueDate ? params.dueDate.toISOString().split("T")[0] : undefined, notes: params.notes, }; diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts index fd2b09c0..9cb4559a 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts @@ -48,8 +48,16 @@ export class WhmcsPaymentService { clientid: clientId, }); - // Use consolidated array shape - const paymentMethodsArray: WhmcsPaymentMethod[] = Array.isArray(response?.paymethods) + 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 : []; @@ -69,9 +77,9 @@ export class WhmcsPaymentService { .filter((method): method is PaymentMethod => method !== null); // Mark the first method as default (per product decision) - methods = methods.map((m, i) => - i === 0 ? { ...m, isDefault: true } : { ...m, isDefault: false } - ); + if (methods.length > 0) { + methods = methods.map((m, index) => ({ ...m, isDefault: index === 0 })); + } const result: PaymentMethodList = { paymentMethods: methods, totalCount: methods.length }; if (!options?.fresh) { diff --git a/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts index cc6a47c0..8a3a0d39 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts @@ -168,14 +168,17 @@ export class InvoiceTransformerService { "Draft", "Unpaid", "Paid", + "Pending", "Cancelled", "Refunded", "Collections", "Overdue", ]; - if (allowed.includes(status as Invoice["status"])) { - return status as Invoice["status"]; + const normalizedStatus = status === "Payment Pending" ? "Pending" : status; + + if (allowed.includes(normalizedStatus as Invoice["status"])) { + return normalizedStatus as Invoice["status"]; } throw new Error(`Unsupported WHMCS invoice status: ${status}`); diff --git a/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts index 0ee269bb..03f9e551 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts @@ -49,18 +49,18 @@ export class PaymentTransformerService { id: whmcsPayMethod.id, type: whmcsPayMethod.type, description: whmcsPayMethod.description, - gatewayName: whmcsPayMethod.gateway_name ?? undefined, + gatewayName: whmcsPayMethod.gateway_name || undefined, + contactType: whmcsPayMethod.contact_type || undefined, + contactId: whmcsPayMethod.contact_id ?? undefined, + cardLastFour: whmcsPayMethod.card_last_four || undefined, + expiryDate: whmcsPayMethod.expiry_date || undefined, + startDate: whmcsPayMethod.start_date || undefined, + issueNumber: whmcsPayMethod.issue_number || undefined, + cardType: whmcsPayMethod.card_type || undefined, + remoteToken: whmcsPayMethod.remote_token || undefined, + lastUpdated: whmcsPayMethod.last_updated || undefined, + bankName: whmcsPayMethod.bank_name || undefined, isDefault: false, - lastFour: whmcsPayMethod.card_last_four ?? undefined, - expiryDate: whmcsPayMethod.expiry_date ?? undefined, - bankName: whmcsPayMethod.bank_name ?? undefined, - accountType: whmcsPayMethod.account_type ?? undefined, - remoteToken: whmcsPayMethod.remote_token ?? undefined, - ccType: whmcsPayMethod.card_type ?? undefined, - cardBrand: whmcsPayMethod.card_type ?? undefined, - billingContactId: whmcsPayMethod.billing_contact_id ?? undefined, - createdAt: whmcsPayMethod.created_at ?? undefined, - updatedAt: whmcsPayMethod.updated_at ?? undefined, }; if (!this.validator.validatePaymentMethod(transformed)) { diff --git a/apps/bff/src/integrations/whmcs/transformers/services/whmcs-transformer-orchestrator.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/whmcs-transformer-orchestrator.service.ts index 3721fbdf..3acc1659 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/whmcs-transformer-orchestrator.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/whmcs-transformer-orchestrator.service.ts @@ -126,7 +126,7 @@ export class WhmcsTransformerOrchestratorService { } catch (error) { this.logger.error("Payment method transformation failed in orchestrator", { error: DataUtils.toErrorMessage(error), - payMethodId: whmcsPayMethod?.id || whmcsPayMethod?.paymethodid, + payMethodId: whmcsPayMethod?.id, }); throw error; } @@ -141,7 +141,7 @@ export class WhmcsTransformerOrchestratorService { } catch (error) { this.logger.error("Payment method transformation failed in orchestrator", { error: DataUtils.toErrorMessage(error), - payMethodId: whmcsPayMethod?.id || whmcsPayMethod?.paymethodid, + payMethodId: whmcsPayMethod?.id, }); throw error; } diff --git a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts index ae8f78af..0288ecfd 100644 --- a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts +++ b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts @@ -297,31 +297,32 @@ export interface WhmcsCatalogProductsResponse { // Payment Method Types export interface WhmcsPaymentMethod { id: number; - paymethodid?: number; type: "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount"; description: string; gateway_name?: string; + contact_type?: string; + contact_id?: number; card_last_four?: string; expiry_date?: string; - bank_name?: string; - account_type?: string; - remote_token?: string; + start_date?: string; + issue_number?: string; card_type?: string; - billing_contact_id?: number; - created_at?: string; - updated_at?: string; + remote_token?: string; + last_updated?: string; + bank_name?: string; } export interface WhmcsPayMethodsResponse { - // Consolidated (preferred) response shape for GetPayMethods - paymethods: WhmcsPaymentMethod[]; - totalresults?: number; + result: "success" | "error"; + clientid?: number | string; + paymethods?: WhmcsPaymentMethod[]; + message?: string; } -export interface WhmcsGetPayMethodsParams { +export interface WhmcsGetPayMethodsParams extends Record { clientid: number; paymethodid?: number; - [key: string]: unknown; + type?: "BankAccount" | "CreditCard"; } // Payment Gateway Types @@ -353,7 +354,8 @@ export interface WhmcsCreateInvoiceParams { | "Cancelled" | "Refunded" | "Collections" - | "Overdue"; + | "Overdue" + | "Payment Pending"; sendnotification?: boolean; paymentmethod?: string; taxrate?: number; diff --git a/apps/bff/src/modules/auth/application/auth.facade.ts b/apps/bff/src/modules/auth/application/auth.facade.ts index d1591627..e3f9e033 100644 --- a/apps/bff/src/modules/auth/application/auth.facade.ts +++ b/apps/bff/src/modules/auth/application/auth.facade.ts @@ -20,12 +20,12 @@ import { import type { User as PrismaUser } from "@prisma/client"; import type { Request } from "express"; import { PrismaService } from "@bff/infra/database/prisma.service"; -import { TokenBlacklistService } from "@bff/modules/auth/infra/token/token-blacklist.service"; -import { AuthTokenService } from "@bff/modules/auth/infra/token/token.service"; -import { AuthRateLimitService } from "@bff/modules/auth/infra/rate-limiting/auth-rate-limit.service"; -import { SignupWorkflowService } from "@bff/modules/auth/infra/workflows/signup-workflow.service"; -import { PasswordWorkflowService } from "@bff/modules/auth/infra/workflows/password-workflow.service"; -import { WhmcsLinkWorkflowService } from "@bff/modules/auth/infra/workflows/whmcs-link-workflow.service"; +import { TokenBlacklistService } from "../infra/token/token-blacklist.service"; +import { AuthTokenService } from "../infra/token/token.service"; +import { AuthRateLimitService } from "../infra/rate-limiting/auth-rate-limit.service"; +import { SignupWorkflowService } from "../infra/workflows/workflows/signup-workflow.service"; +import { PasswordWorkflowService } from "../infra/workflows/workflows/password-workflow.service"; +import { WhmcsLinkWorkflowService } from "../infra/workflows/workflows/whmcs-link-workflow.service"; import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util"; @Injectable() diff --git a/apps/bff/src/modules/auth/auth-admin.controller.ts b/apps/bff/src/modules/auth/auth-admin.controller.ts deleted file mode 100644 index 267653fe..00000000 --- a/apps/bff/src/modules/auth/auth-admin.controller.ts +++ /dev/null @@ -1,132 +0,0 @@ -import { - Controller, - Get, - Post, - Param, - UseGuards, - Query, - BadRequestException, - UsePipes, -} from "@nestjs/common"; -import { AdminGuard } from "./guards/admin.guard"; -import { AuditService, AuditAction } from "@bff/infra/audit/audit.service"; -import { UsersService } from "@bff/modules/users/users.service"; -import { TokenMigrationService } from "@bff/modules/auth/infra/token/token-migration.service"; -import { ZodValidationPipe } from "@bff/core/validation"; -import { - auditLogQuerySchema, - dryRunQuerySchema, - type AuditLogQuery, - type DryRunQuery, -} from "@customer-portal/domain"; -import { z } from "zod"; - -@UseGuards(AdminGuard) -@Controller("auth/admin") -export class AuthAdminController { - constructor( - private auditService: AuditService, - private usersService: UsersService, - private tokenMigrationService: TokenMigrationService - ) {} - - @Get("audit-logs") - @UsePipes(new ZodValidationPipe(auditLogQuerySchema)) - async getAuditLogs(@Query() query: AuditLogQuery) { - const { logs, total } = await this.auditService.getAuditLogs({ - page: query.page, - limit: query.limit, - action: query.action as AuditAction | undefined, - userId: query.userId, - }); - - return { - logs, - pagination: { - page: query.page, - limit: query.limit, - total, - totalPages: Math.ceil(total / query.limit), - }, - }; - } - - @Post("unlock-account/:userId") - async unlockAccount(@Param("userId") userId: string) { - const user = await this.usersService.findById(userId); - if (!user) { - throw new BadRequestException("User not found"); - } - - await this.usersService.update(userId, { - failedLoginAttempts: 0, - lockedUntil: null, - }); - - await this.auditService.log({ - userId, - action: AuditAction.ACCOUNT_UNLOCKED, - resource: "auth", - details: { adminAction: true, email: user.email }, - success: true, - }); - - return { message: "Account unlocked successfully" }; - } - - @Get("security-stats") - async getSecurityStats() { - return this.auditService.getSecurityStats(); - } - - @Get("token-migration/status") - async getTokenMigrationStatus() { - return this.tokenMigrationService.getMigrationStatus(); - } - - @Post("token-migration/run") - @UsePipes(new ZodValidationPipe(dryRunQuerySchema)) - async runTokenMigration(@Query() query: DryRunQuery) { - const isDryRun = query.dryRun ?? true; - const stats = await this.tokenMigrationService.migrateExistingTokens(isDryRun); - - await this.auditService.log({ - action: AuditAction.SYSTEM_MAINTENANCE, - resource: "auth", - details: { - operation: "token_migration", - dryRun: isDryRun, - stats, - }, - success: true, - }); - - return { - message: isDryRun ? "Migration dry run completed" : "Migration completed", - stats, - }; - } - - @Post("token-migration/cleanup") - @UsePipes(new ZodValidationPipe(dryRunQuerySchema)) - async cleanupOrphanedTokens(@Query() query: DryRunQuery) { - const isDryRun = query.dryRun ?? true; - const stats = await this.tokenMigrationService.cleanupOrphanedTokens(isDryRun); - - await this.auditService.log({ - action: AuditAction.SYSTEM_MAINTENANCE, - resource: "auth", - details: { - operation: "token_cleanup", - dryRun: isDryRun, - stats, - }, - success: true, - }); - - return { - message: isDryRun ? "Cleanup dry run completed" : "Cleanup completed", - stats, - }; - } -} diff --git a/apps/bff/src/modules/auth/auth.module.ts b/apps/bff/src/modules/auth/auth.module.ts index 3a6cdac9..4e2068e5 100644 --- a/apps/bff/src/modules/auth/auth.module.ts +++ b/apps/bff/src/modules/auth/auth.module.ts @@ -5,7 +5,6 @@ import { ConfigService } from "@nestjs/config"; import { APP_GUARD } from "@nestjs/core"; import { AuthFacade } from "./application/auth.facade"; import { AuthController } from "./presentation/http/auth.controller"; -import { AuthAdminController } from "./auth-admin.controller"; import { UsersModule } from "@bff/modules/users/users.module"; import { MappingsModule } from "@bff/modules/id-mappings/mappings.module"; import { IntegrationsModule } from "@bff/integrations/integrations.module"; @@ -15,10 +14,9 @@ import { GlobalAuthGuard } from "./presentation/http/guards/global-auth.guard"; import { TokenBlacklistService } from "./infra/token/token-blacklist.service"; import { EmailModule } from "@bff/infra/email/email.module"; import { AuthTokenService } from "./infra/token/token.service"; -import { TokenMigrationService } from "./infra/token/token-migration.service"; -import { SignupWorkflowService } from "./infra/workflows/signup-workflow.service"; -import { PasswordWorkflowService } from "./infra/workflows/password-workflow.service"; -import { WhmcsLinkWorkflowService } from "./infra/workflows/whmcs-link-workflow.service"; +import { SignupWorkflowService } from "./infra/workflows/workflows/signup-workflow.service"; +import { PasswordWorkflowService } from "./infra/workflows/workflows/password-workflow.service"; +import { WhmcsLinkWorkflowService } from "./infra/workflows/workflows/whmcs-link-workflow.service"; import { FailedLoginThrottleGuard } from "./presentation/http/guards/failed-login-throttle.guard"; import { LoginResultInterceptor } from "./presentation/http/interceptors/login-result.interceptor"; import { AuthRateLimitService } from "./infra/rate-limiting/auth-rate-limit.service"; @@ -38,14 +36,13 @@ import { AuthRateLimitService } from "./infra/rate-limiting/auth-rate-limit.serv IntegrationsModule, EmailModule, ], - controllers: [AuthController, AuthAdminController], + controllers: [AuthController], providers: [ AuthFacade, JwtStrategy, LocalStrategy, TokenBlacklistService, AuthTokenService, - TokenMigrationService, SignupWorkflowService, PasswordWorkflowService, WhmcsLinkWorkflowService, @@ -57,6 +54,6 @@ import { AuthRateLimitService } from "./infra/rate-limiting/auth-rate-limit.serv useClass: GlobalAuthGuard, }, ], - exports: [AuthFacade, TokenBlacklistService, AuthTokenService, TokenMigrationService], + exports: [AuthFacade, TokenBlacklistService, AuthTokenService], }) export class AuthModule {} diff --git a/apps/bff/src/modules/auth/infra/token/token-blacklist.service.ts b/apps/bff/src/modules/auth/infra/token/token-blacklist.service.ts index aa042924..352afc2f 100644 --- a/apps/bff/src/modules/auth/infra/token/token-blacklist.service.ts +++ b/apps/bff/src/modules/auth/infra/token/token-blacklist.service.ts @@ -4,7 +4,7 @@ import { JwtService } from "@nestjs/jwt"; import { createHash } from "crypto"; import { Redis } from "ioredis"; import { Logger } from "nestjs-pino"; -import { parseJwtExpiry } from "../utils/jwt-expiry.util"; +import { parseJwtExpiry } from "../../utils/jwt-expiry.util"; @Injectable() export class TokenBlacklistService { diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts index b457eabe..cd4132e2 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/password-workflow.service.ts @@ -8,8 +8,8 @@ import { UsersService } from "@bff/modules/users/users.service"; import { AuditService, AuditAction } from "@bff/infra/audit/audit.service"; import { EmailService } from "@bff/infra/email/email.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import { AuthTokenService } from "../token.service"; -import { AuthRateLimitService } from "../auth-rate-limit.service"; +import { AuthTokenService } from "../../token/token.service"; +import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service"; import { type AuthTokens, type UserProfile } from "@customer-portal/domain"; import { mapPrismaUserToUserProfile } from "@bff/infra/utils/user-mapper.util"; diff --git a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts index f1846249..66a57e36 100644 --- a/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts +++ b/apps/bff/src/modules/auth/infra/workflows/workflows/signup-workflow.service.ts @@ -15,8 +15,8 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { PrismaService } from "@bff/infra/database/prisma.service"; -import { AuthTokenService } from "../token.service"; -import { AuthRateLimitService } from "../auth-rate-limit.service"; +import { AuthTokenService } from "../../token/token.service"; +import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import { signupRequestSchema, diff --git a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts index e8051f50..212b46e0 100644 --- a/apps/bff/src/modules/auth/presentation/http/auth.controller.ts +++ b/apps/bff/src/modules/auth/presentation/http/auth.controller.ts @@ -17,7 +17,7 @@ import { LocalAuthGuard } from "./guards/local-auth.guard"; import { AuthThrottleGuard } from "./guards/auth-throttle.guard"; import { FailedLoginThrottleGuard } from "./guards/failed-login-throttle.guard"; import { LoginResultInterceptor } from "./interceptors/login-result.interceptor"; -import { Public } from "./decorators/public.decorator"; +import { Public } from "../../decorators/public.decorator"; import { ZodValidationPipe } from "@bff/core/validation"; // Import Zod schemas from domain @@ -49,19 +49,35 @@ import type { AuthTokens } from "@customer-portal/domain"; type RequestWithCookies = Request & { cookies?: Record }; -const EXTRACT_BEARER = (req: RequestWithCookies): string | undefined => { - const authHeader = req.headers?.authorization; - if (typeof authHeader === "string" && authHeader.startsWith("Bearer ")) { - return authHeader.slice(7); +const resolveAuthorizationHeader = (req: RequestWithCookies): string | undefined => { + const rawHeader = req.headers?.authorization; + + if (typeof rawHeader === "string") { + return rawHeader; } - if (Array.isArray(authHeader) && authHeader.length > 0 && authHeader[0]?.startsWith("Bearer ")) { - return authHeader[0]?.slice(7); + + if (Array.isArray(rawHeader)) { + const headerValues: string[] = rawHeader; + for (const candidate of headerValues) { + if (typeof candidate === "string" && candidate.trim().length > 0) { + return candidate; + } + } + } + + return undefined; +}; + +const extractBearerToken = (req: RequestWithCookies): string | undefined => { + const authHeader = resolveAuthorizationHeader(req); + if (authHeader && authHeader.startsWith("Bearer ")) { + return authHeader.slice(7); } return undefined; }; const extractTokenFromRequest = (req: RequestWithCookies): string | undefined => { - const headerToken = EXTRACT_BEARER(req); + const headerToken = extractBearerToken(req); if (headerToken) { return headerToken; } @@ -225,7 +241,7 @@ export class AuthController { @Post("request-password-reset") @Throttle({ default: { limit: 5, ttl: 900000 } }) // 5 attempts per 15 minutes (standard for password operations) @UsePipes(new ZodValidationPipe(passwordResetRequestSchema)) - async requestPasswordReset(@Body() body: PasswordResetRequestInput) { + async requestPasswordReset(@Body() body: PasswordResetRequestInput, @Req() req: Request) { await this.authFacade.requestPasswordReset(body.email, req); return { message: "If an account exists, a reset email has been sent" }; } diff --git a/apps/bff/src/modules/auth/presentation/http/guards/admin.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/admin.guard.ts deleted file mode 100644 index 991dc4f1..00000000 --- a/apps/bff/src/modules/auth/presentation/http/guards/admin.guard.ts +++ /dev/null @@ -1,21 +0,0 @@ -import { Injectable, CanActivate, ExecutionContext, ForbiddenException } from "@nestjs/common"; -import { UserRole } from "@prisma/client"; -import type { Request } from "express"; - -@Injectable() -export class AdminGuard implements CanActivate { - canActivate(context: ExecutionContext): boolean { - const request = context.switchToHttp().getRequest(); - const user = request.user; - - if (!user) { - throw new ForbiddenException("Authentication required"); - } - - if (user.role !== UserRole.ADMIN) { - throw new ForbiddenException("Admin access required"); - } - - return true; - } -} diff --git a/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts index 18b20dda..3fff3f94 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/failed-login-throttle.guard.ts @@ -1,6 +1,6 @@ import { Injectable, ExecutionContext } from "@nestjs/common"; import type { Request } from "express"; -import { AuthRateLimitService } from "@bff/modules/auth/infra/rate-limiting/auth-rate-limit.service"; +import { AuthRateLimitService } from "../../../infra/rate-limiting/auth-rate-limit.service"; @Injectable() export class FailedLoginThrottleGuard { diff --git a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts index c1c0bb35..8d6d5186 100644 --- a/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts +++ b/apps/bff/src/modules/auth/presentation/http/guards/global-auth.guard.ts @@ -11,8 +11,8 @@ import { ExtractJwt } from "passport-jwt"; import type { Request } from "express"; -import { TokenBlacklistService } from "@bff/modules/auth/infra/token/token-blacklist.service"; -import { IS_PUBLIC_KEY } from "@bff/modules/auth/decorators/public.decorator"; +import { TokenBlacklistService } from "../../../infra/token/token-blacklist.service"; +import { IS_PUBLIC_KEY } from "../../../decorators/public.decorator"; type RequestWithCookies = Request & { cookies?: Record }; diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts index 5a5d8ce4..389c7f26 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-orchestrator.service.ts @@ -124,6 +124,10 @@ export class OrderFulfillmentOrchestrator { } // Step 3: Execute the main fulfillment workflow as a distributed transaction + let mappingResult: OrderItemMappingResult | undefined; + let whmcsCreateResult: { orderId: number } | undefined; + let whmcsAcceptResult: WhmcsOrderResult | undefined; + const fulfillmentResult = await this.distributedTransactionService.executeDistributedTransaction( [ @@ -146,14 +150,22 @@ export class OrderFulfillmentOrchestrator { }, critical: true, }, + { + id: "order_details", + description: "Retain order details in context", + execute: () => Promise.resolve(context.orderDetails), + critical: false, + }, { id: "mapping", description: "Map OrderItems to WHMCS format", - execute: async () => { + execute: () => { if (!context.orderDetails) { - throw new Error("Order details are required for mapping"); + return Promise.reject(new Error("Order details are required for mapping")); } - return this.orderWhmcsMapper.mapOrderItemsToWhmcs(context.orderDetails.items); + const result = this.orderWhmcsMapper.mapOrderItemsToWhmcs(context.orderDetails.items); + mappingResult = result; + return Promise.resolve(result); }, critical: true, }, @@ -161,7 +173,9 @@ export class OrderFulfillmentOrchestrator { id: "whmcs_create", description: "Create order in WHMCS", execute: async () => { - const mappingResult = fulfillmentResult.stepResults?.mapping; + if (!context.validation) { + throw new Error("Validation context is missing"); + } if (!mappingResult) { throw new Error("Mapping result is not available"); } @@ -171,8 +185,8 @@ export class OrderFulfillmentOrchestrator { `Provisioned from Salesforce Order ${sfOrderId}` ); - return await this.whmcsOrderService.addOrder({ - clientId: context.validation!.clientId, + const result = await this.whmcsOrderService.addOrder({ + clientId: context.validation.clientId, items: mappingResult.whmcsItems, paymentMethod: "stripe", promoCode: "1st Month Free (Monthly Plan)", @@ -181,21 +195,24 @@ export class OrderFulfillmentOrchestrator { noinvoiceemail: true, noemail: true, }); + + whmcsCreateResult = result; + return result; }, - rollback: async () => { - const createResult = fulfillmentResult.stepResults?.whmcs_create; - if (createResult?.orderId) { + rollback: () => { + if (whmcsCreateResult?.orderId) { // Note: WHMCS doesn't have an automated cancel API // Manual intervention required for order cleanup this.logger.error( "WHMCS order created but fulfillment failed - manual cleanup required", { - orderId: createResult.orderId, + orderId: whmcsCreateResult.orderId, sfOrderId, action: "MANUAL_CLEANUP_REQUIRED", } ); } + return Promise.resolve(); }, critical: true, }, @@ -203,28 +220,33 @@ export class OrderFulfillmentOrchestrator { id: "whmcs_accept", description: "Accept/provision order in WHMCS", execute: async () => { - const createResult = fulfillmentResult.stepResults?.whmcs_create; - if (!createResult?.orderId) { + if (!whmcsCreateResult?.orderId) { throw new Error("WHMCS order ID missing before acceptance step"); } - return await this.whmcsOrderService.acceptOrder(createResult.orderId, sfOrderId); + const result = await this.whmcsOrderService.acceptOrder( + whmcsCreateResult.orderId, + sfOrderId + ); + + whmcsAcceptResult = result; + return result; }, - rollback: async () => { - const acceptResult = fulfillmentResult.stepResults?.whmcs_accept; - if (acceptResult?.orderId) { + rollback: () => { + if (whmcsAcceptResult?.orderId) { // Note: WHMCS doesn't have an automated cancel API for accepted orders // Manual intervention required for service termination this.logger.error( "WHMCS order accepted but fulfillment failed - manual cleanup required", { - orderId: acceptResult.orderId, - serviceIds: acceptResult.serviceIds, + orderId: whmcsAcceptResult.orderId, + serviceIds: whmcsAcceptResult.serviceIds, sfOrderId, action: "MANUAL_SERVICE_TERMINATION_REQUIRED", } ); } + return Promise.resolve(); }, critical: true, }, @@ -238,9 +260,9 @@ export class OrderFulfillmentOrchestrator { orderDetails: context.orderDetails, configurations, }); - return { completed: true }; + return { completed: true as const }; } - return { skipped: true }; + return { skipped: true as const }; }, critical: false, // SIM fulfillment failure shouldn't rollback the entire order }, @@ -249,13 +271,12 @@ export class OrderFulfillmentOrchestrator { description: "Update Salesforce with success", execute: async () => { const fields = this.fieldMapService.getFieldMap(); - const whmcsResult = fulfillmentResult.stepResults?.whmcs_accept; return await this.salesforceService.updateOrder({ Id: sfOrderId, Status: "Completed", [fields.order.activationStatus]: "Activated", - [fields.order.whmcsOrderId]: whmcsResult?.orderId?.toString(), + [fields.order.whmcsOrderId]: whmcsAcceptResult?.orderId?.toString(), }); }, rollback: async () => { @@ -286,8 +307,8 @@ export class OrderFulfillmentOrchestrator { } // Update context with results - context.mappingResult = fulfillmentResult.stepResults?.mapping; - context.whmcsResult = fulfillmentResult.stepResults?.whmcs_accept; + context.mappingResult = mappingResult; + context.whmcsResult = whmcsAcceptResult; this.logger.log("Transactional fulfillment completed successfully", { sfOrderId, diff --git a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts index 846f1fe3..cc3341e7 100644 --- a/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts @@ -6,7 +6,6 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import type { SalesforceOrderRecord } from "@customer-portal/domain"; import { SalesforceFieldMapService, type SalesforceFieldMap } from "@bff/core/config/field-map"; -import { orderFulfillmentValidationSchema } from "@customer-portal/domain"; import { sfOrderIdParamSchema } from "@customer-portal/domain"; type OrderStringFieldKey = "activationStatus"; @@ -36,9 +35,11 @@ export class OrderFulfillmentValidator { * Validates SF order, gets client ID, checks payment method, checks idempotency */ async validateFulfillmentRequest( - sfOrderId: string, + rawSfOrderId: string, idempotencyKey: string ): Promise { + const { sfOrderId } = sfOrderIdParamSchema.parse({ sfOrderId: rawSfOrderId }); + this.logger.log("Starting fulfillment validation", { sfOrderId, idempotencyKey, diff --git a/apps/bff/src/types/rate-limiter-flexible.d.ts b/apps/bff/src/types/rate-limiter-flexible.d.ts new file mode 100644 index 00000000..4965db95 --- /dev/null +++ b/apps/bff/src/types/rate-limiter-flexible.d.ts @@ -0,0 +1,40 @@ +import type { Redis } from "ioredis"; + +declare module "rate-limiter-flexible" { + export interface RateLimiterOptions { + storeClient: Redis; + points: number; + duration: number; + blockDuration?: number; + keyPrefix?: string; + inmemoryBlockOnConsumed?: number; + insuranceLimiter?: RateLimiterMemory; + } + + export class RateLimiterRes { + remainingPoints: number; + consumedPoints: number; + msBeforeNext: number; + constructor(data: { + remainingPoints: number; + consumedPoints: number; + msBeforeNext: number; + }); + } + + export class RateLimiterRedis { + readonly points: number; + constructor(options: RateLimiterOptions); + consume(key: string, points?: number): Promise; + delete(key: string): Promise; + penalty(key: string, points?: number): Promise; + reward(key: string, points?: number): Promise; + } + + export class RateLimiterMemory { + readonly points: number; + constructor(options: RateLimiterOptions); + consume(key: string, points?: number): Promise; + delete(key: string): Promise; + } +} diff --git a/apps/portal/src/features/billing/components/PaymentMethodCard.tsx b/apps/portal/src/features/billing/components/PaymentMethodCard.tsx index 898a1839..2e4ed9d7 100644 --- a/apps/portal/src/features/billing/components/PaymentMethodCard.tsx +++ b/apps/portal/src/features/billing/components/PaymentMethodCard.tsx @@ -2,7 +2,6 @@ import { CreditCardIcon, BanknotesIcon, DevicePhoneMobileIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; import type { PaymentMethod } from "@customer-portal/domain"; -import { StatusPill } from "@/components/atoms/status-pill"; import { cn } from "@/lib/utils"; import type { ReactNode } from "react"; @@ -53,16 +52,15 @@ const isBankAccount = (type: PaymentMethod["type"]) => type === "BankAccount" || type === "RemoteBankAccount"; const formatCardDisplay = (method: PaymentMethod) => { - // Show ***** and last 4 digits for any payment method with lastFour - if (method.lastFour) { - return `***** ${method.lastFour}`; + if (method.cardLastFour) { + return `***** ${method.cardLastFour}`; } - + // Fallback based on type if (isCreditCard(method.type)) { - return method.cardBrand ? `${method.cardBrand.toUpperCase()} Card` : "Credit Card"; + return method.cardType ? `${method.cardType.toUpperCase()} Card` : "Credit Card"; } - + if (isBankAccount(method.type)) { return method.bankName || "Bank Account"; } @@ -71,10 +69,10 @@ const formatCardDisplay = (method: PaymentMethod) => { }; const formatCardBrand = (method: PaymentMethod) => { - if (isCreditCard(method.type) && method.cardBrand) { - return method.cardBrand.toUpperCase(); + if (isCreditCard(method.type) && method.cardType) { + return method.cardType.toUpperCase(); } - + if (isBankAccount(method.type) && method.bankName) { return method.bankName; } @@ -96,7 +94,7 @@ export function PaymentMethodCard({ const cardDisplay = formatCardDisplay(paymentMethod); const cardBrand = formatCardBrand(paymentMethod); const expiry = formatExpiry(paymentMethod.expiryDate); - const icon = getMethodIcon(paymentMethod.type, paymentMethod.cardBrand ?? paymentMethod.ccType); + const icon = getMethodIcon(paymentMethod.type, paymentMethod.cardType); return (
( }, ref ) => { - const { - type, - description, - gatewayDisplayName, - isDefault, - lastFour, - expiryDate, - bankName, - accountType, - ccType, - cardBrand, - } = paymentMethod; + const { type, description, gatewayName, isDefault, expiryDate, bankName, cardType, cardLastFour } = + paymentMethod; const formatExpiryDate = (expiry?: string) => { if (!expiry) return null; @@ -92,13 +82,12 @@ const PaymentMethodCard = forwardRef( }; const renderPaymentMethodDetails = () => { - if (type === "BankAccount") { + if (type === "BankAccount" || type === "RemoteBankAccount") { return (
{bankName || "Bank Account"}
- {accountType && {accountType}} - {lastFour && •••• {lastFour}} + {cardLastFour && •••• {cardLastFour}}
); @@ -108,12 +97,12 @@ const PaymentMethodCard = forwardRef( return (
- {cardBrand || ccType || "Credit Card"} - {lastFour && •••• {lastFour}} + {cardType || "Credit Card"} + {cardLastFour && •••• {cardLastFour}}
{formatExpiryDate(expiryDate) && Expires {formatExpiryDate(expiryDate)}} - {gatewayDisplayName && • {gatewayDisplayName}} + {gatewayName && • {gatewayName}}
); @@ -139,7 +128,7 @@ const PaymentMethodCard = forwardRef( "flex-shrink-0 rounded-lg flex items-center justify-center", compact ? "w-8 h-8 bg-gray-100" : "w-10 h-10 bg-gray-100", type === "CreditCard" || type === "RemoteCreditCard" - ? getCardBrandColor(cardBrand) + ? getCardBrandColor(cardType) : "text-gray-600" )} > @@ -151,7 +140,7 @@ const PaymentMethodCard = forwardRef( {renderPaymentMethodDetails()} {/* Description if different from generated details */} - {description && !description.includes(lastFour || "") && ( + {description && !description.includes(cardLastFour || "") && (
{description}
)} @@ -184,8 +173,8 @@ const PaymentMethodCard = forwardRef(
{/* Gateway info for compact view */} - {compact && gatewayDisplayName && ( -
via {gatewayDisplayName}
+ {compact && gatewayName && ( +
via {gatewayName}
)} ); diff --git a/apps/portal/src/features/billing/hooks/useBilling.ts b/apps/portal/src/features/billing/hooks/useBilling.ts index 0293117d..1602e3f9 100644 --- a/apps/portal/src/features/billing/hooks/useBilling.ts +++ b/apps/portal/src/features/billing/hooks/useBilling.ts @@ -10,6 +10,7 @@ import { } from "@tanstack/react-query"; import { apiClient, queryKeys, getDataOrDefault, getDataOrThrow } from "@/lib/api"; import type { InvoiceQueryParams } from "@/lib/api/types"; +import type { QueryParams } from "@/lib/api/runtime/client"; import type { Invoice, InvoiceList, @@ -85,10 +86,24 @@ type SsoLinkMutationOptions = UseMutationOptions< { invoiceId: number; target?: "view" | "download" | "pay" } >; +const toQueryParams = (params: InvoiceQueryParams): QueryParams => { + const query: QueryParams = {}; + for (const [key, value] of Object.entries(params)) { + if (value === undefined) { + continue; + } + if (typeof value === "string" || typeof value === "number" || typeof value === "boolean") { + query[key] = value; + } + } + return query; +}; + async function fetchInvoices(params?: InvoiceQueryParams): Promise { + const query = params ? toQueryParams(params) : undefined; const response = await apiClient.GET( "/api/invoices", - params ? { params: { query: params as Record } } : undefined + query ? { params: { query } } : undefined ); const data = getDataOrDefault(response, emptyInvoiceList); const parsed = invoiceListSchema.parse(data); @@ -113,9 +128,9 @@ export function useInvoices( params?: InvoiceQueryParams, options?: InvoicesQueryOptions ): UseQueryResult { - const queryParams = params ? (params as Record) : {}; + const queryKeyParams = params ? { ...params } : undefined; return useQuery({ - queryKey: queryKeys.billing.invoices(queryParams), + queryKey: queryKeys.billing.invoices(queryKeyParams), queryFn: () => fetchInvoices(params), ...options, }); diff --git a/apps/portal/src/features/catalog/components/base/PaymentForm.tsx b/apps/portal/src/features/catalog/components/base/PaymentForm.tsx index 3f9851af..ee313072 100644 --- a/apps/portal/src/features/catalog/components/base/PaymentForm.tsx +++ b/apps/portal/src/features/catalog/components/base/PaymentForm.tsx @@ -90,8 +90,8 @@ export function PaymentForm({ const renderMethod = (method: PaymentMethod) => { const methodId = String(method.id); const isSelected = selectedMethod === methodId; - const label = method.cardBrand - ? `${method.cardBrand.toUpperCase()} ${method.lastFour ? `•••• ${method.lastFour}` : ""}`.trim() + const label = method.cardType + ? `${method.cardType.toUpperCase()} ${method.cardLastFour ? `•••• ${method.cardLastFour}` : ""}`.trim() : (method.description ?? method.type); return ( diff --git a/apps/portal/src/lib/api/runtime/client.ts b/apps/portal/src/lib/api/runtime/client.ts index ad24c0a8..8f9b3eb0 100644 --- a/apps/portal/src/lib/api/runtime/client.ts +++ b/apps/portal/src/lib/api/runtime/client.ts @@ -13,11 +13,18 @@ export class ApiError extends Error { export const isApiError = (error: unknown): error is ApiError => error instanceof ApiError; -type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; +export type HttpMethod = + | "GET" + | "POST" + | "PUT" + | "PATCH" + | "DELETE" + | "HEAD" + | "OPTIONS"; type PathParams = Record; -type QueryPrimitive = string | number | boolean; -type QueryParams = Record< +export type QueryPrimitive = string | number | boolean; +export type QueryParams = Record< string, QueryPrimitive | QueryPrimitive[] | readonly QueryPrimitive[] | undefined >; @@ -138,11 +145,11 @@ const buildQueryString = (query?: QueryParams): string => { } if (Array.isArray(value)) { - value.forEach(entry => appendPrimitive(key, entry)); + (value as readonly QueryPrimitive[]).forEach(entry => appendPrimitive(key, entry)); continue; } - appendPrimitive(key, value); + appendPrimitive(key, value as QueryPrimitive); } return searchParams.toString(); diff --git a/docs/AUTH-MODULE-ARCHITECTURE.md b/docs/AUTH-MODULE-ARCHITECTURE.md index 2a1e327d..e355f915 100644 --- a/docs/AUTH-MODULE-ARCHITECTURE.md +++ b/docs/AUTH-MODULE-ARCHITECTURE.md @@ -44,7 +44,6 @@ modules/auth/ - `GlobalAuthGuard`: wraps Passport JWT, checks blacklist via `TokenBlacklistService`. - `AuthThrottleGuard`: Nest Throttler-based guard for general rate limits. - `FailedLoginThrottleGuard`: uses `AuthRateLimitService` for login attempt limiting. -- `AdminGuard`: ensures authenticated user has admin role (uses Prisma enum). ### Interceptors - `LoginResultInterceptor`: ties into `FailedLoginThrottleGuard` to clear counters on success/failure. @@ -65,7 +64,6 @@ modules/auth/ ### Token (`infra/token`) - `token.service.ts`: issues/rotates access + refresh tokens, enforces Redis-backed refresh token families. - `token-blacklist.service.ts`: stores revoked access tokens. -- `token-migration.service.ts`: helpers for legacy flows. ### Rate Limiting (`infra/rate-limiting/auth-rate-limit.service.ts`) - Built on `rate-limiter-flexible` with Redis storage. diff --git a/docs/REDIS-TOKEN-FLOW-IMPLEMENTATION.md b/docs/REDIS-TOKEN-FLOW-IMPLEMENTATION.md index bb145e42..36e46e32 100644 --- a/docs/REDIS-TOKEN-FLOW-IMPLEMENTATION.md +++ b/docs/REDIS-TOKEN-FLOW-IMPLEMENTATION.md @@ -98,35 +98,7 @@ This document summarizes the implementation of the Redis-required token flow wit ### 5. Migration Utilities for Existing Keys -**New Service:** `TokenMigrationService` - -- Migrates existing refresh tokens to new per-user structure -- Handles orphaned token cleanup -- Provides migration statistics and status -- Supports dry-run mode for safe testing - -**Admin Endpoints Added:** - -- `GET /auth/admin/token-migration/status`: Check migration status -- `POST /auth/admin/token-migration/run?dryRun=true`: Run migration -- `POST /auth/admin/token-migration/cleanup?dryRun=true`: Cleanup orphaned tokens - -**Migration Features:** - -- Scans existing refresh token families and tokens -- Creates per-user token sets for existing tokens -- Identifies and removes orphaned tokens -- Comprehensive logging and error handling -- Audit trail for all migration operations - -**Files Created:** - -- `apps/bff/src/modules/auth/services/token-migration.service.ts` - -**Files Modified:** - -- `apps/bff/src/modules/auth/auth.module.ts` -- `apps/bff/src/modules/auth/auth-admin.controller.ts` +Legacy helpers (`token-migration.service.ts`) have been removed along with the admin-only migration endpoints. ## 🚀 Deployment Instructions @@ -158,28 +130,7 @@ SF_QUEUE_LONG_RUNNING_TIMEOUT_MS=600000 ### 2. Migration Process -1. **Check Migration Status:** - - ```bash - GET /auth/admin/token-migration/status - ``` - -2. **Run Dry-Run Migration:** - - ```bash - POST /auth/admin/token-migration/run?dryRun=true - ``` - -3. **Execute Actual Migration:** - - ```bash - POST /auth/admin/token-migration/run?dryRun=false - ``` - -4. **Cleanup Orphaned Tokens:** - ```bash - POST /auth/admin/token-migration/cleanup?dryRun=false - ``` +Legacy admin migration endpoints were removed. If migration is needed in the future, plan a manual script or one-off job. ### 3. Feature Flag Rollout @@ -263,7 +214,6 @@ SF_QUEUE_INTERVAL_CAP=800 - [ ] Token migration dry-run and execution - [ ] Per-user token limit enforcement - [ ] Orphaned token cleanup -- [ ] Admin endpoint security (admin-only access) ## 🔄 Rollback Plan diff --git a/packages/domain/src/entities/payment.ts b/packages/domain/src/entities/payment.ts index 63412947..f1976adc 100644 --- a/packages/domain/src/entities/payment.ts +++ b/packages/domain/src/entities/payment.ts @@ -5,17 +5,17 @@ export interface PaymentMethod extends WhmcsEntity { type: "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount" | "Manual"; description: string; gatewayName?: string; - isDefault?: boolean; - lastFour?: string; + contactType?: string; + contactId?: number; + cardLastFour?: string; expiryDate?: string; - bankName?: string; - accountType?: string; + startDate?: string; + issueNumber?: string; + cardType?: string; remoteToken?: string; - ccType?: string; - cardBrand?: string; - billingContactId?: number; - createdAt?: string; - updatedAt?: string; + lastUpdated?: string; + bankName?: string; + isDefault?: boolean; } export interface PaymentMethodList { diff --git a/packages/domain/src/validation/api/requests.ts b/packages/domain/src/validation/api/requests.ts index 2118456a..5828f130 100644 --- a/packages/domain/src/validation/api/requests.ts +++ b/packages/domain/src/validation/api/requests.ts @@ -14,6 +14,7 @@ import { requiredAddressSchema, genderEnum, } from "../shared/primitives"; +import { invoiceStatusSchema } from "../shared/entities"; const invoiceStatusEnum = z.enum(["Paid", "Unpaid", "Overdue", "Cancelled", "Collections"]); const subscriptionStatusEnum = z.enum([ @@ -269,7 +270,7 @@ export const invoiceItemSchema = z.object({ export const invoiceSchema = z.object({ id: z.number().int().positive(), number: z.string().min(1, "Invoice number is required"), - status: z.string().min(1, "Status is required"), + status: invoiceStatusSchema, currency: z.string().length(3, "Currency must be 3 characters"), currencySymbol: z.string().optional(), total: z.number().nonnegative("Total must be non-negative"), diff --git a/packages/domain/src/validation/shared/entities.ts b/packages/domain/src/validation/shared/entities.ts index 53fa1ef6..71b8b09e 100644 --- a/packages/domain/src/validation/shared/entities.ts +++ b/packages/domain/src/validation/shared/entities.ts @@ -55,15 +55,15 @@ const paymentMethodTypeSchema = z.enum([ "Manual", ]); -const orderStatusSchema = z.enum(["Pending", "Active", "Cancelled", "Fraud"]); -const invoiceStatusSchema = z.enum(tupleFromEnum(INVOICE_STATUS)); -const subscriptionStatusSchema = z.enum(tupleFromEnum(SUBSCRIPTION_STATUS)); -const caseStatusSchema = z.enum(tupleFromEnum(CASE_STATUS)); -const casePrioritySchema = z.enum(tupleFromEnum(CASE_PRIORITY)); -const paymentStatusSchema = z.enum(tupleFromEnum(PAYMENT_STATUS)); -const caseTypeSchema = z.enum(["Question", "Problem", "Feature Request"]); +export const orderStatusSchema = z.enum(["Pending", "Active", "Cancelled", "Fraud"]); +export const invoiceStatusSchema = z.enum(tupleFromEnum(INVOICE_STATUS)); +export const subscriptionStatusSchema = z.enum(tupleFromEnum(SUBSCRIPTION_STATUS)); +export const caseStatusSchema = z.enum(tupleFromEnum(CASE_STATUS)); +export const casePrioritySchema = z.enum(tupleFromEnum(CASE_PRIORITY)); +export const paymentStatusSchema = z.enum(tupleFromEnum(PAYMENT_STATUS)); +export const caseTypeSchema = z.enum(["Question", "Problem", "Feature Request"]); -const subscriptionCycleSchema = z.enum([ +export const subscriptionCycleSchema = z.enum([ "Monthly", "Quarterly", "Semi-Annually", @@ -220,18 +220,17 @@ export const paymentMethodSchema = whmcsEntitySchema.extend({ type: paymentMethodTypeSchema, description: z.string().min(1, "Payment method description is required"), gatewayName: z.string().optional(), - gatewayDisplayName: z.string().optional(), - isDefault: z.boolean().optional(), - lastFour: z.string().length(4, "Last four must be exactly 4 digits").optional(), + contactType: z.string().optional(), + contactId: z.number().int().positive().optional(), + cardLastFour: z.string().length(4, "Last four must be exactly 4 digits").optional(), expiryDate: z.string().optional(), - bankName: z.string().optional(), - accountType: z.string().optional(), + startDate: z.string().optional(), + issueNumber: z.string().optional(), + cardType: z.string().optional(), remoteToken: z.string().optional(), - ccType: z.string().optional(), - cardBrand: z.string().optional(), - billingContactId: z.number().int().positive().optional(), - createdAt: z.string().optional(), - updatedAt: z.string().optional(), + lastUpdated: z.string().optional(), + bankName: z.string().optional(), + isDefault: z.boolean().optional(), }); export const paymentSchema = z.object({