import { Injectable, Inject, BadRequestException, NotFoundException } from "@nestjs/common"; import { subscriptionListSchema, subscriptionStatusSchema, subscriptionStatsSchema, } from "@customer-portal/domain/subscriptions"; import type { Subscription, SubscriptionList, SubscriptionStatus, } from "@customer-portal/domain/subscriptions"; import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing"; import { WhmcsCacheService } from "@bff/integrations/whmcs/cache/whmcs-cache.service.js"; import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js"; import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js"; import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js"; import { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs-subscription.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { Logger } from "nestjs-pino"; import { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.util.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; export interface GetSubscriptionsOptions { status?: SubscriptionStatus; } /** * Subscriptions Orchestrator * * Coordinates subscription management operations across multiple * integration services (WHMCS, Salesforce). */ @Injectable() export class SubscriptionsOrchestrator { // eslint-disable-next-line max-params -- NestJS DI requires individual constructor injection constructor( private readonly whmcsSubscriptionService: WhmcsSubscriptionService, private readonly whmcsInvoiceService: WhmcsInvoiceService, private readonly whmcsClientService: WhmcsClientService, private readonly whmcsConnectionService: WhmcsConnectionFacade, private readonly cacheService: WhmcsCacheService, private readonly mappingsService: MappingsService, @Inject(Logger) private readonly logger: Logger ) {} /** * Get all subscriptions for a user */ async getSubscriptions( userId: string, options: GetSubscriptionsOptions = {} ): Promise { const { status } = options; const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); return safeOperation( async () => { const subscriptionList = await this.whmcsSubscriptionService.getSubscriptions( whmcsClientId, userId, status === undefined ? {} : { status } ); const parsed = subscriptionListSchema.parse(subscriptionList); let subscriptions = parsed.subscriptions; if (status) { const normalizedStatus = subscriptionStatusSchema.parse(status); subscriptions = subscriptions.filter(sub => sub.status === normalizedStatus); } return subscriptionListSchema.parse({ subscriptions, totalCount: subscriptions.length, }); }, { criticality: OperationCriticality.CRITICAL, context: `Get subscriptions for user ${userId}`, logger: this.logger, rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Failed to retrieve subscriptions", } ); } /** * Get individual subscription by ID */ async getSubscriptionById(userId: string, subscriptionId: number): Promise { // Validate subscription ID if (!subscriptionId || subscriptionId < 1) { throw new BadRequestException("Subscription ID must be a positive number"); } // Get WHMCS client ID from user mapping const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); return safeOperation( async () => { const subscription = await this.whmcsSubscriptionService.getSubscriptionById( whmcsClientId, userId, subscriptionId ); this.logger.log(`Retrieved subscription ${subscriptionId} for user ${userId}`, { productName: subscription.productName, status: subscription.status, amount: subscription.amount, currency: subscription.currency, }); return subscription; }, { criticality: OperationCriticality.CRITICAL, context: `Get subscription ${subscriptionId} for user ${userId}`, logger: this.logger, rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Failed to retrieve subscription", } ); } /** * Get active subscriptions for a user */ async getActiveSubscriptions(userId: string): Promise { try { const subscriptionList = await this.getSubscriptions(userId, { status: "Active", }); return subscriptionList.subscriptions; } catch (error) { this.logger.error(`Failed to get active subscriptions for user ${userId}`, { error: extractErrorMessage(error), }); throw error; } } /** * Get subscriptions by status */ async getSubscriptionsByStatus( userId: string, status: SubscriptionStatus ): Promise { try { const normalizedStatus = subscriptionStatusSchema.parse(status); const subscriptionList = await this.getSubscriptions(userId, { status: normalizedStatus }); return subscriptionList.subscriptions; } catch (error) { this.logger.error(`Failed to get ${status} subscriptions for user ${userId}`, { error: extractErrorMessage(error), }); throw error; } } /** * Get subscription statistics for a user */ async getSubscriptionStats(userId: string): Promise<{ total: number; active: number; completed: number; cancelled: number; }> { return safeOperation( async () => { const subscriptionList = await this.getSubscriptions(userId); const subscriptions: Subscription[] = subscriptionList.subscriptions; const stats = { total: subscriptions.length, active: subscriptions.filter(s => s.status === "Active").length, completed: subscriptions.filter(s => s.status === "Completed").length, cancelled: subscriptions.filter(s => s.status === "Cancelled").length, }; this.logger.log(`Generated subscription stats for user ${userId}`, stats); return subscriptionStatsSchema.parse(stats); }, { criticality: OperationCriticality.CRITICAL, context: `Generate subscription stats for user ${userId}`, logger: this.logger, rethrow: [NotFoundException, BadRequestException], } ); } /** * Get subscriptions expiring soon (within next 30 days) */ async getExpiringSoon(userId: string, days: number = 30): Promise { return safeOperation( async () => { const subscriptionList = await this.getSubscriptions(userId); const subscriptions: Subscription[] = subscriptionList.subscriptions; const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() + days); const expiringSoon = subscriptions.filter(subscription => { if (!subscription.nextDue || subscription.status !== "Active") { return false; } const nextDueDate = new Date(subscription.nextDue); return nextDueDate <= cutoffDate; }); this.logger.log( `Found ${expiringSoon.length} subscriptions expiring within ${days} days for user ${userId}` ); return expiringSoon; }, { criticality: OperationCriticality.CRITICAL, context: `Get expiring subscriptions for user ${userId}`, logger: this.logger, rethrow: [NotFoundException, BadRequestException], } ); } /** * Get recent subscription activity (newly created or status changed) */ async getRecentActivity(userId: string, days: number = 30): Promise { return safeOperation( async () => { const subscriptionList = await this.getSubscriptions(userId); const subscriptions = subscriptionList.subscriptions; const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - days); const recentActivity = subscriptions.filter((subscription: Subscription) => { const registrationDate = new Date(subscription.registrationDate); return registrationDate >= cutoffDate; }); this.logger.log( `Found ${recentActivity.length} recent subscription activities within ${days} days for user ${userId}` ); return recentActivity; }, { criticality: OperationCriticality.CRITICAL, context: `Get recent subscription activity for user ${userId}`, logger: this.logger, rethrow: [NotFoundException, BadRequestException], } ); } /** * Search subscriptions by product name or domain */ async searchSubscriptions(userId: string, query: string): Promise { if (!query || query.trim().length < 2) { throw new BadRequestException("Search query must be at least 2 characters long"); } return safeOperation( async () => { const subscriptionList = await this.getSubscriptions(userId); const subscriptions = subscriptionList.subscriptions; const searchTerm = query.toLowerCase().trim(); const matches = subscriptions.filter((subscription: Subscription) => { const productName = subscription.productName.toLowerCase(); const domain = subscription.domain?.toLowerCase() || ""; return productName.includes(searchTerm) || domain.includes(searchTerm); }); this.logger.log( `Found ${matches.length} subscriptions matching query "${query}" for user ${userId}` ); return matches; }, { criticality: OperationCriticality.CRITICAL, context: `Search subscriptions for user ${userId}`, logger: this.logger, rethrow: [NotFoundException, BadRequestException], } ); } /** * Get invoices related to a specific subscription */ async getSubscriptionInvoices( userId: string, subscriptionId: number, options: { page?: number; limit?: number } = {} ): Promise { const { page = 1, limit = 10 } = options; return safeOperation( async () => { const cachedResult = await this.tryGetCachedInvoices(userId, subscriptionId, page, limit); if (cachedResult) return cachedResult; await this.getSubscriptionById(userId, subscriptionId); const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); const relatedInvoices = await this.fetchAllRelatedInvoices( whmcsClientId, userId, subscriptionId, limit ); const result = this.paginateInvoices(relatedInvoices, page, limit); await this.cacheInvoiceResults({ userId, subscriptionId, page, limit, result, allInvoices: relatedInvoices, }); this.logger.log("Retrieved invoices for subscription", { userId, subscriptionId, count: result.invoices.length, totalRelated: relatedInvoices.length, }); return result; }, { criticality: OperationCriticality.CRITICAL, context: `Get invoices for subscription ${subscriptionId}`, logger: this.logger, rethrow: [NotFoundException, BadRequestException], fallbackMessage: "Failed to retrieve subscription invoices", } ); } private async tryGetCachedInvoices( userId: string, subscriptionId: number, page: number, limit: number ): Promise { const cached = await this.cacheService.getSubscriptionInvoices( userId, subscriptionId, page, limit ); if (cached) { this.logger.debug("Cache hit for subscription invoices", { userId, subscriptionId }); return cached; } const cachedAll = await this.cacheService.getSubscriptionInvoicesAll(userId, subscriptionId); if (cachedAll) { const result = this.paginateInvoices(cachedAll, page, limit); await this.cacheService.setSubscriptionInvoices(userId, subscriptionId, page, limit, result); return result; } return null; } private async fetchAllRelatedInvoices( whmcsClientId: number, userId: string, subscriptionId: number, limit: number ): Promise { const batchSize = Math.min(100, Math.max(limit, 25)); const relatedInvoices: Invoice[] = []; let currentPage = 1; let totalPages = 1; do { // eslint-disable-next-line no-await-in-loop -- Sequential pagination required by WHMCS API const invoiceBatch = await this.whmcsInvoiceService.getInvoicesWithItems( whmcsClientId, userId, { page: currentPage, limit: batchSize } ); totalPages = invoiceBatch.pagination.totalPages; for (const invoice of invoiceBatch.invoices) { if (!invoice.items?.length) continue; const hasMatch = invoice.items.some( (item: InvoiceItem) => item.serviceId === subscriptionId ); if (hasMatch) relatedInvoices.push(invoice); } currentPage += 1; } while (currentPage <= totalPages); return relatedInvoices; } private paginateInvoices(invoices: Invoice[], page: number, limit: number): InvoiceList { const startIndex = (page - 1) * limit; const paginatedInvoices = invoices.slice(startIndex, startIndex + limit); return { invoices: paginatedInvoices, pagination: { page, totalPages: invoices.length === 0 ? 0 : Math.ceil(invoices.length / limit), totalItems: invoices.length, }, }; } private async cacheInvoiceResults(options: { userId: string; subscriptionId: number; page: number; limit: number; result: InvoiceList; allInvoices: Invoice[]; }): Promise { const { userId, subscriptionId, page, limit, result, allInvoices } = options; await this.cacheService.setSubscriptionInvoices(userId, subscriptionId, page, limit, result); await this.cacheService.setSubscriptionInvoicesAll(userId, subscriptionId, allInvoices); } /** * Invalidate subscription cache for a user */ async invalidateCache(userId: string, subscriptionId?: number): Promise { try { if (subscriptionId) { await this.whmcsSubscriptionService.invalidateSubscriptionCache(userId, subscriptionId); } else { await this.whmcsClientService.invalidateUserCache(userId); } const subscriptionSuffix = subscriptionId ? `, subscription ${subscriptionId}` : ""; this.logger.log(`Invalidated subscription cache for user ${userId}${subscriptionSuffix}`); } catch (error) { this.logger.error(`Failed to invalidate subscription cache for user ${userId}`, { error: extractErrorMessage(error), subscriptionId, }); } } /** * Health check for subscription service */ async healthCheck(): Promise<{ status: string; details: unknown }> { try { const whmcsHealthy = await this.whmcsConnectionService.healthCheck(); return { status: whmcsHealthy ? "healthy" : "unhealthy", details: { whmcsApi: whmcsHealthy ? "connected" : "disconnected", timestamp: new Date().toISOString(), }, }; } catch (error) { this.logger.error("Subscription service health check failed", { error: extractErrorMessage(error), }); return { status: "unhealthy", details: { error: extractErrorMessage(error), timestamp: new Date().toISOString(), }, }; } } }