532 lines
16 KiB
TypeScript
532 lines
16 KiB
TypeScript
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<SubscriptionList> {
|
|
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<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: 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<Subscription[]> {
|
|
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<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: 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: 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 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<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: 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<boolean> {
|
|
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;
|
|
}
|
|
}
|
|
}
|