- Enhance safeOperation with rethrow and fallbackMessage options for CRITICAL operations - Migrate all 19 withErrorHandling calls across 5 services to safeOperation - Remove safeAsync from error.util.ts - Delete error-handler.util.ts (withErrorHandling, withErrorSuppression, withErrorLogging) - Update barrel exports in core/utils/index.ts
489 lines
16 KiB
TypeScript
489 lines
16 KiB
TypeScript
import { Injectable, Inject, BadRequestException, NotFoundException } 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 { safeOperation, OperationCriticality } from "@bff/core/utils/safe-operation.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 safeOperation(
|
|
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,
|
|
});
|
|
},
|
|
{
|
|
criticality: OperationCriticality.CRITICAL,
|
|
context: `Get subscriptions for user ${userId}`,
|
|
logger: this.logger,
|
|
rethrow: [NotFoundException, BadRequestException],
|
|
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 safeOperation(
|
|
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;
|
|
},
|
|
{
|
|
criticality: OperationCriticality.CRITICAL,
|
|
context: `Get subscription ${subscriptionId} for user ${userId}`,
|
|
logger: this.logger,
|
|
rethrow: [NotFoundException, BadRequestException],
|
|
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 safeOperation(
|
|
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);
|
|
},
|
|
{
|
|
criticality: OperationCriticality.CRITICAL,
|
|
context: `Generate subscription stats for user ${userId}`,
|
|
logger: this.logger,
|
|
rethrow: [NotFoundException, BadRequestException],
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get subscriptions expiring soon (within next 30 days)
|
|
*/
|
|
async getExpiringSoon(userId: string, days: number = 30): Promise<Subscription[]> {
|
|
return safeOperation(
|
|
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;
|
|
},
|
|
{
|
|
criticality: OperationCriticality.CRITICAL,
|
|
context: `Get expiring subscriptions for user ${userId}`,
|
|
logger: this.logger,
|
|
rethrow: [NotFoundException, BadRequestException],
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Get recent subscription activity (newly created or status changed)
|
|
*/
|
|
async getRecentActivity(userId: string, days: number = 30): Promise<Subscription[]> {
|
|
return safeOperation(
|
|
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;
|
|
},
|
|
{
|
|
criticality: OperationCriticality.CRITICAL,
|
|
context: `Get recent subscription activity for user ${userId}`,
|
|
logger: this.logger,
|
|
rethrow: [NotFoundException, BadRequestException],
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 safeOperation(
|
|
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;
|
|
},
|
|
{
|
|
criticality: OperationCriticality.CRITICAL,
|
|
context: `Search subscriptions for user ${userId}`,
|
|
logger: this.logger,
|
|
rethrow: [NotFoundException, BadRequestException],
|
|
}
|
|
);
|
|
}
|
|
|
|
/**
|
|
* 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 safeOperation(
|
|
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;
|
|
},
|
|
{
|
|
criticality: OperationCriticality.CRITICAL,
|
|
context: `Get invoices for subscription ${subscriptionId}`,
|
|
logger: this.logger,
|
|
rethrow: [NotFoundException, BadRequestException],
|
|
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(),
|
|
},
|
|
};
|
|
}
|
|
}
|
|
}
|