import { getErrorMessage } from "../common/utils/error.util"; import { Injectable, NotFoundException, Inject } from "@nestjs/common"; import { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/shared"; import { WhmcsService } from "../vendors/whmcs/whmcs.service"; import { MappingsService } from "../mappings/mappings.service"; import { Logger } from "nestjs-pino"; export interface GetSubscriptionsOptions { status?: string; } @Injectable() export class SubscriptionsService { constructor( private readonly whmcsService: WhmcsService, 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; try { // Get WHMCS client ID from user mapping const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { throw new NotFoundException("WHMCS client mapping not found"); } // Fetch subscriptions from WHMCS const subscriptionList = await this.whmcsService.getSubscriptions( mapping.whmcsClientId, userId, { status } ); this.logger.log( `Retrieved ${subscriptionList.subscriptions.length} subscriptions for user ${userId}`, { status, totalCount: subscriptionList.totalCount, } ); return subscriptionList; } catch (error) { this.logger.error(`Failed to get subscriptions for user ${userId}`, { error: getErrorMessage(error), options, }); if (error instanceof NotFoundException) { throw error; } throw new Error(`Failed to retrieve subscriptions: ${getErrorMessage(error)}`); } } /** * Get individual subscription by ID */ async getSubscriptionById(userId: string, subscriptionId: number): Promise { try { // Validate subscription ID if (!subscriptionId || subscriptionId < 1) { throw new Error("Invalid subscription ID"); } // Get WHMCS client ID from user mapping const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { throw new NotFoundException("WHMCS client mapping not found"); } // Fetch subscription from WHMCS const subscription = await this.whmcsService.getSubscriptionById( mapping.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; } catch (error) { this.logger.error(`Failed to get subscription ${subscriptionId} for user ${userId}`, { error: getErrorMessage(error), }); if (error instanceof NotFoundException) { throw error; } throw new Error(`Failed to retrieve subscription: ${getErrorMessage(error)}`); } } /** * 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: getErrorMessage(error), }); throw error; } } /** * Get subscriptions by status */ async getSubscriptionsByStatus(userId: string, status: string): Promise { try { // Validate status const validStatuses = [ "Active", "Suspended", "Terminated", "Cancelled", "Pending", "Completed", ]; if (!validStatuses.includes(status)) { throw new Error(`Invalid status. Must be one of: ${validStatuses.join(", ")}`); } const subscriptionList = await this.getSubscriptions(userId, { status }); return subscriptionList.subscriptions; } catch (error) { this.logger.error(`Failed to get ${status} subscriptions for user ${userId}`, { error: getErrorMessage(error), }); throw error; } } /** * Get suspended subscriptions for a user */ async getSuspendedSubscriptions(userId: string): Promise { return this.getSubscriptionsByStatus(userId, "Suspended"); } /** * Get cancelled subscriptions for a user */ async getCancelledSubscriptions(userId: string): Promise { return this.getSubscriptionsByStatus(userId, "Cancelled"); } /** * Get pending subscriptions for a user */ async getPendingSubscriptions(userId: string): Promise { return this.getSubscriptionsByStatus(userId, "Pending"); } /** * Get subscription statistics for a user */ async getSubscriptionStats(userId: string): Promise<{ total: number; active: number; suspended: number; cancelled: number; pending: number; completed: number; totalMonthlyRevenue: number; activeMonthlyRevenue: number; currency: string; }> { try { // Get WHMCS client ID from user mapping const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { 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 const subscriptionList = await this.getSubscriptions(userId); const subscriptions = 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, completed: subscriptions.filter(s => s.status === "Completed").length, totalMonthlyRevenue, activeMonthlyRevenue, currency: subscriptions[0]?.currency || "USD", }; this.logger.log(`Generated subscription stats for user ${userId}`, { ...stats, // Don't log revenue amounts for security totalMonthlyRevenue: "[CALCULATED]", activeMonthlyRevenue: "[CALCULATED]", }); return stats; } catch (error) { this.logger.error(`Failed to generate subscription stats for user ${userId}`, { error: getErrorMessage(error), }); throw error; } } /** * Get subscriptions expiring soon (within next 30 days) */ async getExpiringSoon(userId: string, days: number = 30): Promise { try { const subscriptionList = await this.getSubscriptions(userId); const subscriptions = 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; } catch (error) { this.logger.error(`Failed to get expiring subscriptions for user ${userId}`, { error: getErrorMessage(error), days, }); throw error; } } /** * Get recent subscription activity (newly created or status changed) */ async getRecentActivity(userId: string, days: number = 30): Promise { try { const subscriptionList = await this.getSubscriptions(userId); const subscriptions = subscriptionList.subscriptions; const cutoffDate = new Date(); cutoffDate.setDate(cutoffDate.getDate() - days); const recentActivity = subscriptions.filter(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; } catch (error) { this.logger.error(`Failed to get recent subscription activity for user ${userId}`, { error: getErrorMessage(error), days, }); throw error; } } /** * Search subscriptions by product name or domain */ async searchSubscriptions(userId: string, query: string): Promise { try { if (!query || query.trim().length < 2) { throw new Error("Search query must be at least 2 characters long"); } const subscriptionList = await this.getSubscriptions(userId); const subscriptions = subscriptionList.subscriptions; const searchTerm = query.toLowerCase().trim(); const matches = subscriptions.filter(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; } catch (error) { this.logger.error(`Failed to search subscriptions for user ${userId}`, { error: getErrorMessage(error), query, }); throw error; } } /** * 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; try { // Validate subscription exists and belongs to user await this.getSubscriptionById(userId, subscriptionId); // Get WHMCS client ID from user mapping const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { throw new NotFoundException("WHMCS client mapping not found"); } // Get all invoices for the user WITH ITEMS (needed for subscription linking) // TODO: Consider implementing server-side filtering in WHMCS service to improve performance const allInvoices = await this.whmcsService.getInvoicesWithItems( mapping.whmcsClientId, userId, { page: 1, limit: 1000 } // Get more to filter locally ); // Filter invoices that have items related to this subscription // Note: subscriptionId is the same as serviceId in our current WHMCS mapping this.logger.debug( `Filtering ${allInvoices.invoices.length} invoices for subscription ${subscriptionId}`, { totalInvoices: allInvoices.invoices.length, invoicesWithItems: allInvoices.invoices.filter(inv => inv.items && inv.items.length > 0) .length, subscriptionId, } ); const relatedInvoices = allInvoices.invoices.filter(invoice => { const hasItems = invoice.items && invoice.items.length > 0; if (!hasItems) { this.logger.debug(`Invoice ${invoice.id} has no items`); return false; } const hasMatchingService = invoice.items?.some(item => { this.logger.debug( `Checking item: serviceId=${item.serviceId}, subscriptionId=${subscriptionId}`, { itemServiceId: item.serviceId, subscriptionId, matches: item.serviceId === subscriptionId, } ); return item.serviceId === subscriptionId; }); return hasMatchingService; }); // 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: 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, } ); return result; } catch (error) { this.logger.error(`Failed to get invoices for subscription ${subscriptionId}`, { error: getErrorMessage(error), userId, subscriptionId, options, }); if (error instanceof NotFoundException) { throw error; } throw new Error(`Failed to retrieve subscription invoices: ${getErrorMessage(error)}`); } } /** * Invalidate subscription cache for a user */ async invalidateCache(userId: string, subscriptionId?: number): Promise { try { if (subscriptionId) { await this.whmcsService.invalidateSubscriptionCache(userId, subscriptionId); } else { await this.whmcsService.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: getErrorMessage(error), subscriptionId, }); } } /** * Health check for subscription service */ async healthCheck(): Promise<{ status: string; details: any }> { try { const whmcsHealthy = await this.whmcsService.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: getErrorMessage(error), }); return { status: "unhealthy", details: { error: getErrorMessage(error), timestamp: new Date().toISOString(), }, }; } } }