Assist_Design/apps/bff/src/modules/subscriptions/subscriptions-orchestrator.service.ts

475 lines
15 KiB
TypeScript
Raw Normal View History

import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import {
subscriptionListSchema,
subscriptionStatusSchema,
subscriptionStatsSchema,
} from "@customer-portal/domain/subscriptions";
import type {
Subscription,
SubscriptionList,
SubscriptionStatus,
} from "@customer-portal/domain/subscriptions";
import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing";
import { WhmcsCacheService } from "@bff/integrations/whmcs/cache/whmcs-cache.service.js";
import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js";
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js";
import { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs-subscription.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { Logger } from "nestjs-pino";
import { withErrorHandling } from "@bff/core/utils/error-handler.util.js";
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
export interface GetSubscriptionsOptions {
status?: SubscriptionStatus;
}
/**
* Subscriptions Orchestrator
*
* Coordinates subscription management operations across multiple
* integration services (WHMCS, Salesforce).
*/
@Injectable()
export class SubscriptionsOrchestrator {
// eslint-disable-next-line max-params -- NestJS DI requires individual constructor injection
constructor(
private readonly whmcsSubscriptionService: WhmcsSubscriptionService,
private readonly whmcsInvoiceService: WhmcsInvoiceService,
private readonly whmcsClientService: WhmcsClientService,
private readonly whmcsConnectionService: WhmcsConnectionFacade,
private readonly cacheService: WhmcsCacheService,
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;
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
return withErrorHandling(
async () => {
const subscriptionList = await this.whmcsSubscriptionService.getSubscriptions(
whmcsClientId,
userId,
status === undefined ? {} : { status }
);
const parsed = subscriptionListSchema.parse(subscriptionList);
let subscriptions = parsed.subscriptions;
if (status) {
const normalizedStatus = subscriptionStatusSchema.parse(status);
subscriptions = subscriptions.filter(sub => sub.status === normalizedStatus);
}
return subscriptionListSchema.parse({
subscriptions,
totalCount: subscriptions.length,
});
},
this.logger,
{
context: `Get subscriptions for user ${userId}`,
fallbackMessage: "Failed to retrieve subscriptions",
}
);
}
/**
* Get individual subscription by ID
*/
async getSubscriptionById(userId: string, subscriptionId: number): Promise<Subscription> {
// Validate subscription ID
if (!subscriptionId || subscriptionId < 1) {
throw new BadRequestException("Subscription ID must be a positive number");
}
// Get WHMCS client ID from user mapping
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
return withErrorHandling(
async () => {
const subscription = await this.whmcsSubscriptionService.getSubscriptionById(
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;
},
this.logger,
{
context: `Get subscription ${subscriptionId} for user ${userId}`,
fallbackMessage: "Failed to retrieve subscription",
}
);
}
/**
* 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: extractErrorMessage(error),
});
throw error;
}
}
/**
* Get subscriptions by status
*/
async getSubscriptionsByStatus(
userId: string,
status: SubscriptionStatus
): Promise<Subscription[]> {
try {
const normalizedStatus = subscriptionStatusSchema.parse(status);
const subscriptionList = await this.getSubscriptions(userId, { status: normalizedStatus });
return subscriptionList.subscriptions;
} catch (error) {
this.logger.error(`Failed to get ${status} subscriptions for user ${userId}`, {
error: extractErrorMessage(error),
});
throw error;
}
}
/**
* Get subscription statistics for a user
*/
async getSubscriptionStats(userId: string): Promise<{
total: number;
active: number;
completed: number;
cancelled: number;
}> {
return withErrorHandling(
async () => {
const subscriptionList = await this.getSubscriptions(userId);
const subscriptions: Subscription[] = subscriptionList.subscriptions;
const stats = {
total: subscriptions.length,
active: subscriptions.filter(s => s.status === "Active").length,
completed: subscriptions.filter(s => s.status === "Completed").length,
cancelled: subscriptions.filter(s => s.status === "Cancelled").length,
};
this.logger.log(`Generated subscription stats for user ${userId}`, stats);
return subscriptionStatsSchema.parse(stats);
},
this.logger,
{
context: `Generate subscription stats for user ${userId}`,
}
);
}
/**
* Get subscriptions expiring soon (within next 30 days)
*/
async getExpiringSoon(userId: string, days: number = 30): Promise<Subscription[]> {
return withErrorHandling(
async () => {
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;
},
this.logger,
{
context: `Get expiring subscriptions for user ${userId}`,
}
);
}
/**
* Get recent subscription activity (newly created or status changed)
*/
async getRecentActivity(userId: string, days: number = 30): Promise<Subscription[]> {
return withErrorHandling(
async () => {
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;
},
this.logger,
{
context: `Get recent subscription activity for user ${userId}`,
}
);
}
/**
* Search subscriptions by product name or domain
*/
async searchSubscriptions(userId: string, query: string): Promise<Subscription[]> {
if (!query || query.trim().length < 2) {
throw new BadRequestException("Search query must be at least 2 characters long");
}
return withErrorHandling(
async () => {
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;
},
this.logger,
{
context: `Search subscriptions for user ${userId}`,
}
);
}
/**
* 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;
return withErrorHandling(
async () => {
const cachedResult = await this.tryGetCachedInvoices(userId, subscriptionId, page, limit);
if (cachedResult) return cachedResult;
await this.getSubscriptionById(userId, subscriptionId);
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
const relatedInvoices = await this.fetchAllRelatedInvoices(
whmcsClientId,
userId,
subscriptionId,
limit
);
const result = this.paginateInvoices(relatedInvoices, page, limit);
await this.cacheInvoiceResults({
userId,
subscriptionId,
page,
limit,
result,
allInvoices: relatedInvoices,
});
this.logger.log("Retrieved invoices for subscription", {
userId,
subscriptionId,
count: result.invoices.length,
totalRelated: relatedInvoices.length,
});
return result;
},
this.logger,
{
context: `Get invoices for subscription ${subscriptionId}`,
fallbackMessage: "Failed to retrieve subscription invoices",
}
);
}
private async tryGetCachedInvoices(
userId: string,
subscriptionId: number,
page: number,
limit: number
): Promise<InvoiceList | null> {
const cached = await this.cacheService.getSubscriptionInvoices(
userId,
subscriptionId,
page,
limit
);
if (cached) {
this.logger.debug("Cache hit for subscription invoices", { userId, subscriptionId });
return cached;
}
const cachedAll = await this.cacheService.getSubscriptionInvoicesAll(userId, subscriptionId);
if (cachedAll) {
const result = this.paginateInvoices(cachedAll, page, limit);
await this.cacheService.setSubscriptionInvoices(userId, subscriptionId, page, limit, result);
return result;
}
return null;
}
private async fetchAllRelatedInvoices(
whmcsClientId: number,
userId: string,
subscriptionId: number,
limit: number
): Promise<Invoice[]> {
const batchSize = Math.min(100, Math.max(limit, 25));
const relatedInvoices: Invoice[] = [];
let currentPage = 1;
let totalPages = 1;
do {
// eslint-disable-next-line no-await-in-loop -- Sequential pagination required by WHMCS API
const invoiceBatch = await this.whmcsInvoiceService.getInvoicesWithItems(
whmcsClientId,
userId,
{ page: currentPage, limit: batchSize }
);
totalPages = invoiceBatch.pagination.totalPages;
for (const invoice of invoiceBatch.invoices) {
if (!invoice.items?.length) continue;
const hasMatch = invoice.items.some(
(item: InvoiceItem) => item.serviceId === subscriptionId
);
if (hasMatch) relatedInvoices.push(invoice);
}
currentPage += 1;
} while (currentPage <= totalPages);
return relatedInvoices;
}
private paginateInvoices(invoices: Invoice[], page: number, limit: number): InvoiceList {
const startIndex = (page - 1) * limit;
const paginatedInvoices = invoices.slice(startIndex, startIndex + limit);
return {
invoices: paginatedInvoices,
pagination: {
page,
totalPages: invoices.length === 0 ? 0 : Math.ceil(invoices.length / limit),
totalItems: invoices.length,
},
};
}
private async cacheInvoiceResults(options: {
userId: string;
subscriptionId: number;
page: number;
limit: number;
result: InvoiceList;
allInvoices: Invoice[];
}): Promise<void> {
const { userId, subscriptionId, page, limit, result, allInvoices } = options;
await this.cacheService.setSubscriptionInvoices(userId, subscriptionId, page, limit, result);
await this.cacheService.setSubscriptionInvoicesAll(userId, subscriptionId, allInvoices);
}
/**
* Invalidate subscription cache for a user
*/
async invalidateCache(userId: string, subscriptionId?: number): Promise<void> {
try {
if (subscriptionId) {
await this.whmcsSubscriptionService.invalidateSubscriptionCache(userId, subscriptionId);
} else {
await this.whmcsClientService.invalidateUserCache(userId);
}
const subscriptionSuffix = subscriptionId ? `, subscription ${subscriptionId}` : "";
this.logger.log(`Invalidated subscription cache for user ${userId}${subscriptionSuffix}`);
} catch (error) {
this.logger.error(`Failed to invalidate subscription cache for user ${userId}`, {
error: extractErrorMessage(error),
subscriptionId,
});
}
}
/**
* Health check for subscription service
*/
async healthCheck(): Promise<{ status: string; details: unknown }> {
try {
const whmcsHealthy = await this.whmcsConnectionService.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: extractErrorMessage(error),
});
return {
status: "unhealthy",
details: {
error: extractErrorMessage(error),
timestamp: new Date().toISOString(),
},
};
}
}
}