Assist_Design/apps/bff/src/subscriptions/subscriptions.service.ts

472 lines
14 KiB
TypeScript
Raw Normal View History

import { getErrorMessage } from '../common/utils/error.util';
import { Injectable, Logger, NotFoundException } from '@nestjs/common';
import { Subscription, SubscriptionList, InvoiceList } from '@customer-portal/shared';
import { WhmcsService } from '../vendors/whmcs/whmcs.service';
import { MappingsService } from '../mappings/mappings.service';
export interface GetSubscriptionsOptions {
status?: string;
}
@Injectable()
export class SubscriptionsService {
private readonly logger = new Logger(SubscriptionsService.name);
constructor(
private readonly whmcsService: WhmcsService,
private readonly mappingsService: MappingsService,
) {}
/**
* Get all subscriptions for a user
*/
async getSubscriptions(
userId: string,
options: GetSubscriptionsOptions = {}
): Promise<SubscriptionList> {
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<Subscription> {
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<Subscription[]> {
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<Subscription[]> {
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<Subscription[]> {
return this.getSubscriptionsByStatus(userId, 'Suspended');
}
/**
* Get cancelled subscriptions for a user
*/
async getCancelledSubscriptions(userId: string): Promise<Subscription[]> {
return this.getSubscriptionsByStatus(userId, 'Cancelled');
}
/**
* Get pending subscriptions for a user
*/
async getPendingSubscriptions(userId: string): Promise<Subscription[]> {
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<Subscription[]> {
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<Subscription[]> {
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<Subscription[]> {
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<InvoiceList> {
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<void> {
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(),
},
};
}
}
}