import { getErrorMessage } from "@bff/core/utils/error.util"; import { Injectable, NotFoundException, Inject } from "@nestjs/common"; import { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/domain"; import type { Invoice, InvoiceItem } from "@customer-portal/domain"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { Logger } from "nestjs-pino"; import { z } from "zod"; import { subscriptionSchema, } from "@customer-portal/domain/validation/shared/entities"; import type { WhmcsProduct, WhmcsProductsResponse, } from "@bff/integrations/whmcs/types/whmcs-api.types"; 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 { const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { throw new NotFoundException("WHMCS client mapping not found"); } const subscriptionList = await this.whmcsService.getSubscriptions( mapping.whmcsClientId, userId, { status } ); const parsed = z .object({ subscriptions: z.array(subscriptionSchema), totalCount: z.number(), }) .safeParse(subscriptionList); if (!parsed.success) { throw new Error(parsed.error.message); } const filtered = status ? parsed.data.subscriptions.filter(sub => sub.status.toLowerCase() === status.toLowerCase()) : parsed.data.subscriptions; return { subscriptions: filtered, totalCount: filtered.length, } satisfies 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: Subscription[] = subscriptionList.subscriptions; // Calculate revenue metrics const totalMonthlyRevenue = subscriptions .filter(s => s.cycle === "Monthly") .reduce((sum, s) => sum + s.amount, 0); const activeMonthlyRevenue = subscriptions .filter(s => s.status === "Active" && s.cycle === "Monthly") .reduce((sum, s) => sum + s.amount, 0); const stats = { ...basicStats, completed: subscriptions.filter(s => s.status === "Completed").length, totalMonthlyRevenue, activeMonthlyRevenue, currency: subscriptions[0]?.currency || "JPY", }; 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: 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; } 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: 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: 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 invoicesResponse = await this.whmcsService.getInvoicesWithItems( mapping.whmcsClientId, userId, { page: 1, limit: 1000 } // Get more to filter locally ); const allInvoices = invoicesResponse; // 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: Invoice) => inv.items && inv.items.length > 0 ).length, subscriptionId, } ); const relatedInvoices = allInvoices.invoices.filter((invoice: 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: InvoiceItem) => { 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: unknown }> { 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(), }, }; } } private async checkUserHasExistingSim(userId: string): Promise { try { const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { return false; } const productsResponse: WhmcsProductsResponse = await this.whmcsService.getClientsProducts({ clientid: mapping.whmcsClientId, }); const services = productsResponse.products?.product ?? []; return services.some((service: WhmcsProduct) => { const group = typeof service.groupname === "string" ? service.groupname.toLowerCase() : ""; const status = typeof service.status === "string" ? service.status.toLowerCase() : ""; return group.includes("sim") && status === "active"; }); } catch (error: unknown) { this.logger.warn(`Failed to check existing SIM for user ${userId}`, { error: getErrorMessage(error), }); return false; } } }