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

489 lines
15 KiB
TypeScript
Raw Normal View History

2025-08-21 15:24:40 +09:00
import { getErrorMessage } from "../common/utils/error.util";
2025-08-22 17:02:49 +09:00
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
import { Subscription, SubscriptionList, InvoiceList } from "@customer-portal/shared";
2025-08-21 15:24:40 +09:00
import { WhmcsService } from "../vendors/whmcs/whmcs.service";
import { MappingsService } from "../mappings/mappings.service";
2025-08-22 17:02:49 +09:00
import { Logger } from "nestjs-pino";
export interface GetSubscriptionsOptions {
status?: string;
}
@Injectable()
export class SubscriptionsService {
constructor(
private readonly whmcsService: WhmcsService,
private readonly mappingsService: MappingsService,
2025-08-22 17:02:49 +09:00
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Get all subscriptions for a user
*/
async getSubscriptions(
userId: string,
2025-08-22 17:02:49 +09:00
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) {
2025-08-21 15:24:40 +09:00
throw new NotFoundException("WHMCS client mapping not found");
}
// Fetch subscriptions from WHMCS
const subscriptionList = await this.whmcsService.getSubscriptions(
mapping.whmcsClientId,
userId,
2025-08-22 17:02:49 +09:00
{ status }
);
2025-08-21 15:24:40 +09:00
this.logger.log(
`Retrieved ${subscriptionList.subscriptions.length} subscriptions for user ${userId}`,
{
status,
totalCount: subscriptionList.totalCount,
2025-08-22 17:02:49 +09:00
}
2025-08-21 15:24:40 +09:00
);
return subscriptionList;
} catch (error) {
this.logger.error(`Failed to get subscriptions for user ${userId}`, {
error: getErrorMessage(error),
options,
});
2025-08-21 15:24:40 +09:00
if (error instanceof NotFoundException) {
throw error;
}
2025-08-21 15:24:40 +09:00
2025-08-22 17:02:49 +09:00
throw new Error(`Failed to retrieve subscriptions: ${getErrorMessage(error)}`);
}
}
/**
* Get individual subscription by ID
*/
2025-08-22 17:02:49 +09:00
async getSubscriptionById(userId: string, subscriptionId: number): Promise<Subscription> {
try {
// Validate subscription ID
if (!subscriptionId || subscriptionId < 1) {
2025-08-21 15:24:40 +09:00
throw new Error("Invalid subscription ID");
}
// Get WHMCS client ID from user mapping
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
2025-08-21 15:24:40 +09:00
throw new NotFoundException("WHMCS client mapping not found");
}
// Fetch subscription from WHMCS
const subscription = await this.whmcsService.getSubscriptionById(
mapping.whmcsClientId,
userId,
2025-08-22 17:02:49 +09:00
subscriptionId
);
2025-08-22 17:02:49 +09:00
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) {
2025-08-22 17:02:49 +09:00
this.logger.error(`Failed to get subscription ${subscriptionId} for user ${userId}`, {
error: getErrorMessage(error),
});
2025-08-21 15:24:40 +09:00
if (error instanceof NotFoundException) {
throw error;
}
2025-08-21 15:24:40 +09:00
2025-08-22 17:02:49 +09:00
throw new Error(`Failed to retrieve subscription: ${getErrorMessage(error)}`);
}
}
/**
* Get active subscriptions for a user
*/
async getActiveSubscriptions(userId: string): Promise<Subscription[]> {
try {
2025-08-21 15:24:40 +09:00
const subscriptionList = await this.getSubscriptions(userId, {
status: "Active",
});
return subscriptionList.subscriptions;
} catch (error) {
2025-08-22 17:02:49 +09:00
this.logger.error(`Failed to get active subscriptions for user ${userId}`, {
error: getErrorMessage(error),
});
throw error;
}
}
/**
* Get subscriptions by status
*/
2025-08-22 17:02:49 +09:00
async getSubscriptionsByStatus(userId: string, status: string): Promise<Subscription[]> {
try {
// Validate status
2025-08-21 15:24:40 +09:00
const validStatuses = [
"Active",
"Suspended",
"Terminated",
"Cancelled",
"Pending",
"Completed",
];
if (!validStatuses.includes(status)) {
2025-08-22 17:02:49 +09:00
throw new Error(`Invalid status. Must be one of: ${validStatuses.join(", ")}`);
}
const subscriptionList = await this.getSubscriptions(userId, { status });
return subscriptionList.subscriptions;
} catch (error) {
2025-08-22 17:02:49 +09:00
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[]> {
2025-08-21 15:24:40 +09:00
return this.getSubscriptionsByStatus(userId, "Suspended");
}
/**
* Get cancelled subscriptions for a user
*/
async getCancelledSubscriptions(userId: string): Promise<Subscription[]> {
2025-08-21 15:24:40 +09:00
return this.getSubscriptionsByStatus(userId, "Cancelled");
}
/**
* Get pending subscriptions for a user
*/
async getPendingSubscriptions(userId: string): Promise<Subscription[]> {
2025-08-21 15:24:40 +09:00
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) {
2025-08-21 15:24:40 +09:00
throw new NotFoundException("WHMCS client mapping not found");
}
// Get basic stats from WHMCS service
const basicStats = await this.whmcsService.getSubscriptionStats(
mapping.whmcsClientId,
2025-08-22 17:02:49 +09:00
userId
);
// Get all subscriptions for financial calculations
const subscriptionList = await this.getSubscriptions(userId);
const subscriptions = subscriptionList.subscriptions;
// Calculate revenue metrics
const totalMonthlyRevenue = subscriptions
2025-08-22 17:02:49 +09:00
.filter(s => s.cycle === "Monthly")
.reduce((sum, s) => sum + s.amount, 0);
const activeMonthlyRevenue = subscriptions
2025-08-22 17:02:49 +09:00
.filter(s => s.status === "Active" && s.cycle === "Monthly")
.reduce((sum, s) => sum + s.amount, 0);
const stats = {
...basicStats,
2025-08-22 17:02:49 +09:00
completed: subscriptions.filter(s => s.status === "Completed").length,
totalMonthlyRevenue,
activeMonthlyRevenue,
2025-08-21 15:24:40 +09:00
currency: subscriptions[0]?.currency || "USD",
};
this.logger.log(`Generated subscription stats for user ${userId}`, {
...stats,
// Don't log revenue amounts for security
2025-08-21 15:24:40 +09:00
totalMonthlyRevenue: "[CALCULATED]",
activeMonthlyRevenue: "[CALCULATED]",
});
return stats;
} catch (error) {
2025-08-22 17:02:49 +09:00
this.logger.error(`Failed to generate subscription stats for user ${userId}`, {
error: getErrorMessage(error),
});
throw error;
}
}
/**
* Get subscriptions expiring soon (within next 30 days)
*/
2025-08-22 17:02:49 +09:00
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);
2025-08-22 17:02:49 +09:00
const expiringSoon = subscriptions.filter(subscription => {
2025-08-21 15:24:40 +09:00
if (!subscription.nextDue || subscription.status !== "Active") {
return false;
}
const nextDueDate = new Date(subscription.nextDue);
return nextDueDate <= cutoffDate;
});
2025-08-21 15:24:40 +09:00
this.logger.log(
2025-08-22 17:02:49 +09:00
`Found ${expiringSoon.length} subscriptions expiring within ${days} days for user ${userId}`
2025-08-21 15:24:40 +09:00
);
return expiringSoon;
} catch (error) {
2025-08-22 17:02:49 +09:00
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)
*/
2025-08-22 17:02:49 +09:00
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);
2025-08-22 17:02:49 +09:00
const recentActivity = subscriptions.filter(subscription => {
const registrationDate = new Date(subscription.registrationDate);
return registrationDate >= cutoffDate;
});
2025-08-21 15:24:40 +09:00
this.logger.log(
2025-08-22 17:02:49 +09:00
`Found ${recentActivity.length} recent subscription activities within ${days} days for user ${userId}`
2025-08-21 15:24:40 +09:00
);
return recentActivity;
} catch (error) {
2025-08-22 17:02:49 +09:00
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
*/
2025-08-22 17:02:49 +09:00
async searchSubscriptions(userId: string, query: string): Promise<Subscription[]> {
try {
if (!query || query.trim().length < 2) {
2025-08-21 15:24:40 +09:00
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();
2025-08-22 17:02:49 +09:00
const matches = subscriptions.filter(subscription => {
const productName = subscription.productName.toLowerCase();
2025-08-21 15:24:40 +09:00
const domain = subscription.domain?.toLowerCase() || "";
2025-08-21 15:24:40 +09:00
return productName.includes(searchTerm) || domain.includes(searchTerm);
});
2025-08-21 15:24:40 +09:00
this.logger.log(
2025-08-22 17:02:49 +09:00
`Found ${matches.length} subscriptions matching query "${query}" for user ${userId}`
2025-08-21 15:24:40 +09:00
);
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,
2025-08-22 17:02:49 +09:00
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) {
2025-08-21 15:24:40 +09:00
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,
2025-08-22 17:02:49 +09:00
{ 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
2025-08-21 15:24:40 +09:00
this.logger.debug(
`Filtering ${allInvoices.invoices.length} invoices for subscription ${subscriptionId}`,
{
totalInvoices: allInvoices.invoices.length,
2025-08-22 17:02:49 +09:00
invoicesWithItems: allInvoices.invoices.filter(inv => inv.items && inv.items.length > 0)
.length,
2025-08-21 15:24:40 +09:00
subscriptionId,
2025-08-22 17:02:49 +09:00
}
2025-08-21 15:24:40 +09:00
);
2025-08-22 17:02:49 +09:00
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;
}
2025-08-21 15:24:40 +09:00
2025-08-22 17:02:49 +09:00
const hasMatchingService = invoice.items?.some(item => {
2025-08-21 15:24:40 +09:00
this.logger.debug(
`Checking item: serviceId=${item.serviceId}, subscriptionId=${subscriptionId}`,
{
itemServiceId: item.serviceId,
subscriptionId,
matches: item.serviceId === subscriptionId,
2025-08-22 17:02:49 +09:00
}
2025-08-21 15:24:40 +09:00
);
return item.serviceId === subscriptionId;
});
2025-08-21 15:24:40 +09:00
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,
},
};
2025-08-21 15:24:40 +09:00
this.logger.log(
`Retrieved ${paginatedInvoices.length} invoices for subscription ${subscriptionId}`,
{
userId,
subscriptionId,
totalRelated: relatedInvoices.length,
page,
limit,
2025-08-22 17:02:49 +09:00
}
2025-08-21 15:24:40 +09:00
);
return result;
} catch (error) {
2025-08-22 17:02:49 +09:00
this.logger.error(`Failed to get invoices for subscription ${subscriptionId}`, {
error: getErrorMessage(error),
userId,
subscriptionId,
options,
});
2025-08-21 15:24:40 +09:00
if (error instanceof NotFoundException) {
throw error;
}
2025-08-21 15:24:40 +09:00
2025-08-22 17:02:49 +09:00
throw new Error(`Failed to retrieve subscription invoices: ${getErrorMessage(error)}`);
}
}
/**
* Invalidate subscription cache for a user
*/
2025-08-22 17:02:49 +09:00
async invalidateCache(userId: string, subscriptionId?: number): Promise<void> {
try {
if (subscriptionId) {
2025-08-22 17:02:49 +09:00
await this.whmcsService.invalidateSubscriptionCache(userId, subscriptionId);
} else {
await this.whmcsService.invalidateUserCache(userId);
}
2025-08-21 15:24:40 +09:00
this.logger.log(
2025-08-22 17:02:49 +09:00
`Invalidated subscription cache for user ${userId}${subscriptionId ? `, subscription ${subscriptionId}` : ""}`
2025-08-21 15:24:40 +09:00
);
} catch (error) {
2025-08-22 17:02:49 +09:00
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();
2025-08-21 15:24:40 +09:00
return {
2025-08-21 15:24:40 +09:00
status: whmcsHealthy ? "healthy" : "unhealthy",
details: {
2025-08-21 15:24:40 +09:00
whmcsApi: whmcsHealthy ? "connected" : "disconnected",
timestamp: new Date().toISOString(),
},
};
} catch (error) {
2025-08-21 15:24:40 +09:00
this.logger.error("Subscription service health check failed", {
error: getErrorMessage(error),
});
return {
2025-08-21 15:24:40 +09:00
status: "unhealthy",
details: {
error: getErrorMessage(error),
timestamp: new Date().toISOString(),
},
};
}
}
}