2025-12-10 16:08:34 +09:00
|
|
|
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
2025-08-22 17:02:49 +09:00
|
|
|
import { Logger } from "nestjs-pino";
|
|
|
|
|
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
2025-12-10 16:08:34 +09:00
|
|
|
import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions.js";
|
|
|
|
|
import { Providers } from "@customer-portal/domain/subscriptions";
|
|
|
|
|
import type { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
|
|
|
|
|
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js";
|
|
|
|
|
import { WhmcsCurrencyService } from "./whmcs-currency.service.js";
|
|
|
|
|
import { WhmcsCacheService } from "../cache/whmcs-cache.service.js";
|
|
|
|
|
import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions";
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
export interface SubscriptionFilters {
|
|
|
|
|
status?: string;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class WhmcsSubscriptionService {
|
|
|
|
|
constructor(
|
2025-08-22 17:02:49 +09:00
|
|
|
@Inject(Logger) private readonly logger: Logger,
|
2025-09-25 16:38:21 +09:00
|
|
|
private readonly connectionService: WhmcsConnectionOrchestratorService,
|
2025-10-08 13:03:31 +09:00
|
|
|
private readonly currencyService: WhmcsCurrencyService,
|
2025-08-22 17:02:49 +09:00
|
|
|
private readonly cacheService: WhmcsCacheService
|
2025-08-20 18:02:50 +09:00
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get client subscriptions/services with caching
|
|
|
|
|
*/
|
|
|
|
|
async getSubscriptions(
|
|
|
|
|
clientId: number,
|
|
|
|
|
userId: string,
|
|
|
|
|
filters: SubscriptionFilters = {}
|
|
|
|
|
): Promise<SubscriptionList> {
|
|
|
|
|
try {
|
|
|
|
|
// Try cache first
|
|
|
|
|
const cached = await this.cacheService.getSubscriptionsList(userId);
|
|
|
|
|
if (cached) {
|
|
|
|
|
this.logger.debug(`Cache hit for subscriptions: user ${userId}`);
|
2025-08-22 17:02:49 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
// Apply status filter if needed
|
|
|
|
|
if (filters.status) {
|
2025-10-29 18:59:17 +09:00
|
|
|
return Providers.Whmcs.filterSubscriptionsByStatus(cached, filters.status);
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
2025-08-22 17:02:49 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
return cached;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch from WHMCS API
|
|
|
|
|
const params: WhmcsGetClientsProductsParams = {
|
|
|
|
|
clientid: clientId,
|
2025-08-22 17:02:49 +09:00
|
|
|
orderby: "regdate",
|
|
|
|
|
order: "DESC",
|
2025-08-20 18:02:50 +09:00
|
|
|
};
|
|
|
|
|
|
2025-10-21 14:41:22 +09:00
|
|
|
const rawResponse = await this.connectionService.getClientsProducts(params);
|
2025-08-20 18:02:50 +09:00
|
|
|
|
2025-10-21 14:41:22 +09:00
|
|
|
if (!rawResponse) {
|
|
|
|
|
this.logger.error("WHMCS GetClientsProducts returned empty response", {
|
|
|
|
|
clientId,
|
|
|
|
|
});
|
2025-10-28 13:43:45 +09:00
|
|
|
throw new WhmcsOperationException("GetClientsProducts call failed", {
|
|
|
|
|
clientId,
|
|
|
|
|
});
|
2025-10-21 14:41:22 +09:00
|
|
|
}
|
|
|
|
|
|
2025-10-29 18:59:17 +09:00
|
|
|
const defaultCurrency = this.currencyService.getDefaultCurrency();
|
|
|
|
|
let result: SubscriptionList;
|
|
|
|
|
try {
|
|
|
|
|
result = Providers.Whmcs.transformWhmcsSubscriptionListResponse(rawResponse, {
|
|
|
|
|
defaultCurrencyCode: defaultCurrency.code,
|
|
|
|
|
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
|
|
|
|
|
onItemError: (error, product) => {
|
|
|
|
|
this.logger.error(`Failed to transform subscription ${product.id}`, {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
|
|
|
|
},
|
2025-10-03 11:29:59 +09:00
|
|
|
});
|
2025-10-29 18:59:17 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
const message = error instanceof Error ? error.message : "GetClientsProducts call failed";
|
|
|
|
|
this.logger.error("WHMCS GetClientsProducts returned error result", {
|
2025-10-28 13:43:45 +09:00
|
|
|
clientId,
|
2025-10-29 18:59:17 +09:00
|
|
|
error: getErrorMessage(error),
|
2025-10-28 13:43:45 +09:00
|
|
|
});
|
2025-10-29 18:59:17 +09:00
|
|
|
throw new WhmcsOperationException(message, { clientId });
|
2025-10-03 11:29:59 +09:00
|
|
|
}
|
|
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
// Cache the result
|
|
|
|
|
await this.cacheService.setSubscriptionsList(userId, result);
|
|
|
|
|
|
2025-11-04 11:14:26 +09:00
|
|
|
this.logger.log(
|
|
|
|
|
`Fetched ${result.subscriptions.length} subscriptions for client ${clientId}`
|
|
|
|
|
);
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
// Apply status filter if needed
|
|
|
|
|
if (filters.status) {
|
2025-10-29 18:59:17 +09:00
|
|
|
return Providers.Whmcs.filterSubscriptionsByStatus(result, filters.status);
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return result;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error(`Failed to fetch subscriptions for client ${clientId}`, {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
filters,
|
|
|
|
|
});
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get individual subscription by ID
|
|
|
|
|
*/
|
|
|
|
|
async getSubscriptionById(
|
|
|
|
|
clientId: number,
|
|
|
|
|
userId: string,
|
|
|
|
|
subscriptionId: number
|
|
|
|
|
): Promise<Subscription> {
|
|
|
|
|
try {
|
|
|
|
|
// Try cache first
|
|
|
|
|
const cached = await this.cacheService.getSubscription(userId, subscriptionId);
|
|
|
|
|
if (cached) {
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.debug(
|
|
|
|
|
`Cache hit for subscription: user ${userId}, subscription ${subscriptionId}`
|
|
|
|
|
);
|
2025-08-20 18:02:50 +09:00
|
|
|
return cached;
|
|
|
|
|
}
|
|
|
|
|
|
2025-12-23 15:19:20 +09:00
|
|
|
// 2. Check if we have the FULL list cached.
|
|
|
|
|
// If we do, searching memory is faster than an API call.
|
|
|
|
|
const cachedList = await this.cacheService.getSubscriptionsList(userId);
|
|
|
|
|
if (cachedList) {
|
|
|
|
|
const found = cachedList.subscriptions.find((s: Subscription) => s.id === subscriptionId);
|
|
|
|
|
if (found) {
|
|
|
|
|
this.logger.debug(
|
|
|
|
|
`Cache hit (via list) for subscription: user ${userId}, subscription ${subscriptionId}`
|
|
|
|
|
);
|
|
|
|
|
// Cache this individual item for faster direct access next time
|
|
|
|
|
await this.cacheService.setSubscription(userId, subscriptionId, found);
|
|
|
|
|
return found;
|
|
|
|
|
}
|
|
|
|
|
// If list is cached but item not found, it might be new or not in that list?
|
|
|
|
|
// Proceed to fetch single item.
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 3. Fetch ONLY this subscription from WHMCS (Optimized)
|
|
|
|
|
// Instead of fetching all products, use serviceid filter
|
|
|
|
|
const params: WhmcsGetClientsProductsParams = {
|
|
|
|
|
clientid: clientId,
|
|
|
|
|
serviceid: subscriptionId,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const rawResponse = await this.connectionService.getClientsProducts(params);
|
|
|
|
|
|
|
|
|
|
// Transform response
|
|
|
|
|
const defaultCurrency = this.currencyService.getDefaultCurrency();
|
|
|
|
|
const resultList = Providers.Whmcs.transformWhmcsSubscriptionListResponse(rawResponse, {
|
|
|
|
|
defaultCurrencyCode: defaultCurrency.code,
|
|
|
|
|
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const subscription = resultList.subscriptions.find(s => s.id === subscriptionId);
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
if (!subscription) {
|
|
|
|
|
throw new NotFoundException(`Subscription ${subscriptionId} not found`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Cache the individual subscription
|
|
|
|
|
await this.cacheService.setSubscription(userId, subscriptionId, subscription);
|
|
|
|
|
|
|
|
|
|
this.logger.log(`Fetched subscription ${subscriptionId} for client ${clientId}`);
|
|
|
|
|
return subscription;
|
|
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error(`Failed to fetch subscription ${subscriptionId} for client ${clientId}`, {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
|
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Invalidate cache for a specific subscription
|
|
|
|
|
*/
|
|
|
|
|
async invalidateSubscriptionCache(userId: string, subscriptionId: number): Promise<void> {
|
|
|
|
|
await this.cacheService.invalidateSubscription(userId, subscriptionId);
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.log(
|
|
|
|
|
`Invalidated subscription cache for user ${userId}, subscription ${subscriptionId}`
|
|
|
|
|
);
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|
|
|
|
|
}
|