Assist_Design/apps/bff/src/subscriptions/subscriptions.service.ts
2025-08-21 15:24:40 +09:00

550 lines
15 KiB
TypeScript

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(),
},
};
}
}
}