import { Injectable, Inject, BadRequestException } 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 { withErrorHandling } from "@bff/core/utils/error-handler.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 { 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 withErrorHandling( 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, }); }, this.logger, { context: `Get subscriptions for user ${userId}`, 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 withErrorHandling( 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; }, this.logger, { context: `Get subscription ${subscriptionId} for user ${userId}`, 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 withErrorHandling( 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); }, this.logger, { context: `Generate subscription stats for user ${userId}`, } ); } /** * Get subscriptions expiring soon (within next 30 days) */ async getExpiringSoon(userId: string, days: number = 30): Promise { return withErrorHandling( 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; }, this.logger, { context: `Get expiring subscriptions for user ${userId}`, } ); } /** * Get recent subscription activity (newly created or status changed) */ async getRecentActivity(userId: string, days: number = 30): Promise { return withErrorHandling( 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; }, this.logger, { context: `Get recent subscription activity for user ${userId}`, } ); } /** * 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 withErrorHandling( 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; }, this.logger, { context: `Search subscriptions for user ${userId}`, } ); } /** * 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; const batchSize = Math.min(100, Math.max(limit, 25)); return withErrorHandling( async () => { // Try page cache first const cached = await this.cacheService.getSubscriptionInvoices( userId, subscriptionId, page, limit ); if (cached) { this.logger.debug( `Cache hit for subscription invoices: user ${userId}, subscription ${subscriptionId}` ); return cached; } // Try full list cache to avoid rescanning all WHMCS invoices per page const cachedAll = await this.cacheService.getSubscriptionInvoicesAll( userId, subscriptionId ); if (cachedAll) { const startIndex = (page - 1) * limit; const endIndex = startIndex + limit; const paginatedInvoices = cachedAll.slice(startIndex, endIndex); const result: InvoiceList = { invoices: paginatedInvoices, pagination: { page, totalPages: cachedAll.length === 0 ? 0 : Math.ceil(cachedAll.length / limit), totalItems: cachedAll.length, }, }; await this.cacheService.setSubscriptionInvoices( userId, subscriptionId, page, limit, result ); return result; } // Validate subscription exists and belongs to user await this.getSubscriptionById(userId, subscriptionId); // Get WHMCS client ID from user mapping const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); const relatedInvoices: Invoice[] = []; let currentPage = 1; let totalPages = 1; do { 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 hasMatchingService = invoice.items.some( (item: InvoiceItem) => item.serviceId === subscriptionId ); if (hasMatchingService) { relatedInvoices.push(invoice); } } currentPage += 1; } while (currentPage <= totalPages); // Apply pagination to filtered results const startIndex = (page - 1) * limit; const endIndex = startIndex + limit; const paginatedInvoices = relatedInvoices.slice(startIndex, endIndex); const result: InvoiceList = { invoices: paginatedInvoices, pagination: { page, totalPages: relatedInvoices.length === 0 ? 0 : Math.ceil(relatedInvoices.length / limit), totalItems: relatedInvoices.length, }, }; this.logger.log( `Retrieved ${paginatedInvoices.length} invoices for subscription ${subscriptionId}`, { userId, subscriptionId, totalRelated: relatedInvoices.length, page, limit, } ); // Cache the result await this.cacheService.setSubscriptionInvoices( userId, subscriptionId, page, limit, result ); await this.cacheService.setSubscriptionInvoicesAll(userId, subscriptionId, relatedInvoices); return result; }, this.logger, { context: `Get invoices for subscription ${subscriptionId}`, fallbackMessage: "Failed to retrieve subscription invoices", } ); } /** * 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); } this.logger.log( `Invalidated subscription cache for user ${userId}${subscriptionId ? `, subscription ${subscriptionId}` : ""}` ); } 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(), }, }; } } }