Assist_Design/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts
barsa a3dbd07183 Enhance ESLint Rules and Refactor Domain Imports
- Updated ESLint configuration to enforce stricter import rules for the @customer-portal/domain package, promoting better import hygiene and preventing deep imports.
- Refactored various files across the BFF and portal applications to comply with the new import rules, ensuring that only the appropriate modules are imported from the domain.
- Cleaned up unused imports and optimized code structure for improved maintainability and clarity.
- Updated documentation to reflect changes in import practices and domain structure.
2025-12-26 14:53:03 +09:00

284 lines
9.1 KiB
TypeScript

import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { Logger } from "nestjs-pino";
import { Injectable, Inject } from "@nestjs/common";
import * as Providers from "@customer-portal/domain/payments/providers";
import type {
PaymentMethodList,
PaymentGateway,
PaymentGatewayList,
PaymentMethod,
} from "@customer-portal/domain/payments";
import type { WhmcsCatalogProductNormalized } from "@customer-portal/domain/services/providers";
import * as CatalogProviders from "@customer-portal/domain/services/providers";
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js";
import { WhmcsCacheService } from "../cache/whmcs-cache.service.js";
import type { WhmcsCreateSsoTokenParams } from "@customer-portal/domain/customer/providers";
import type { WhmcsGetPayMethodsParams } from "@customer-portal/domain/payments/providers";
import type {
WhmcsPaymentMethod,
WhmcsPaymentMethodListResponse,
WhmcsPaymentGateway,
WhmcsPaymentGatewayListResponse,
} from "@customer-portal/domain/payments/providers";
@Injectable()
export class WhmcsPaymentService {
constructor(
@Inject(Logger) private readonly logger: Logger,
private readonly connectionService: WhmcsConnectionOrchestratorService,
private readonly cacheService: WhmcsCacheService
) {}
/**
* Get payment methods for a client
*/
async getPaymentMethods(
clientId: number,
userId: string,
options?: { fresh?: boolean }
): Promise<PaymentMethodList> {
try {
// Try cache first unless fresh requested
if (!options?.fresh) {
const cached = await this.cacheService.getPaymentMethods(userId);
if (cached) {
this.logger.debug(`Cache hit for payment methods: user ${userId}`);
return cached;
}
}
// Fetch pay methods (use the documented WHMCS structure)
const params: WhmcsGetPayMethodsParams = {
clientid: clientId,
};
const response: WhmcsPaymentMethodListResponse =
await this.connectionService.getPaymentMethods(params);
const paymentMethodsArray: WhmcsPaymentMethod[] = Array.isArray(response.paymethods)
? response.paymethods
: [];
let methods = paymentMethodsArray
.map((pm: WhmcsPaymentMethod) => {
try {
return Providers.Whmcs.transformWhmcsPaymentMethod(pm);
} catch (error) {
this.logger.error(`Failed to transform payment method`, {
error: getErrorMessage(error),
clientId,
userId,
});
return null;
}
})
.filter((method): method is PaymentMethod => method !== null);
// Mark the first method as default (per product decision)
if (methods.length > 0) {
methods = methods.map((m, index) => ({ ...m, isDefault: index === 0 }));
}
const result: PaymentMethodList = { paymentMethods: methods, totalCount: methods.length };
if (!options?.fresh) {
await this.cacheService.setPaymentMethods(userId, result);
}
return result;
} catch (error) {
this.logger.error(`Failed to fetch payment methods for client ${clientId}`, {
error: getErrorMessage(error),
userId,
});
throw error;
}
}
/**
* Centralized check used by both UI-aligned code paths and worker validation.
* Returns true when the transformed list has at least one saved payment method.
* Pass { fresh: true } to bypass cache for provisioning-time checks.
*/
async hasPaymentMethod(
clientId: number,
userId: string,
options?: { fresh?: boolean }
): Promise<boolean> {
const list = await this.getPaymentMethods(clientId, userId, options);
const count = list?.totalCount || 0;
this.logger.debug("hasPaymentMethod check", { clientId, userId, count });
return count > 0;
}
/**
* Get available payment gateways
*/
async getPaymentGateways(): Promise<PaymentGatewayList> {
try {
// Try cache first
const cached = await this.cacheService.getPaymentGateways();
if (cached) {
this.logger.debug("Cache hit for payment gateways");
return cached;
}
// Fetch from WHMCS API
const response: WhmcsPaymentGatewayListResponse =
await this.connectionService.getPaymentGateways();
if (!response.gateways?.gateway) {
this.logger.warn("No payment gateways found");
return {
gateways: [],
totalCount: 0,
};
}
// Transform payment gateways
const gateways = response.gateways.gateway
.map((whmcsGateway: WhmcsPaymentGateway) => {
try {
return Providers.Whmcs.transformWhmcsPaymentGateway(whmcsGateway);
} catch (error) {
this.logger.error(`Failed to transform payment gateway ${whmcsGateway.name}`, {
error: getErrorMessage(error),
});
return null;
}
})
.filter((gateway): gateway is PaymentGateway => gateway !== null);
const result: PaymentGatewayList = {
gateways,
totalCount: gateways.length,
};
// Cache the result (cache for 1 hour since gateways don't change often)
await this.cacheService.setPaymentGateways(result);
this.logger.log(`Fetched ${gateways.length} payment gateways`);
return result;
} catch (error) {
this.logger.error("Failed to fetch payment gateways", {
error: getErrorMessage(error),
});
throw error;
}
}
/**
* Create SSO token with payment method for invoice payment
* This creates a direct link to the payment form with gateway pre-selected
*/
async createPaymentSsoToken(
clientId: number,
invoiceId: number,
paymentMethodId?: number,
gatewayName?: string
): Promise<{ url: string; expiresAt: string }> {
try {
// Use WHMCS Friendly URL format for direct payment page access
// This goes directly to the payment page, not just invoice view
let invoiceUrl = `index.php?rp=/invoice/${invoiceId}/pay`;
if (paymentMethodId) {
// Pre-select specific saved payment method
invoiceUrl += `&paymentmethod=${paymentMethodId}`;
} else if (gatewayName) {
// Pre-select specific gateway
invoiceUrl += `&gateway=${gatewayName}`;
}
const params: WhmcsCreateSsoTokenParams = {
client_id: clientId,
destination: "sso:custom_redirect",
sso_redirect_path: invoiceUrl,
};
const response = await this.connectionService.createSsoToken(params);
const url = this.resolveRedirectUrl(response.redirect_url);
this.debugLogRedirectHost(url);
const result = {
url,
expiresAt: new Date(Date.now() + 60000).toISOString(), // ~60 seconds (per WHMCS spec)
};
this.logger.log(`Created payment SSO token for client ${clientId}, invoice ${invoiceId}`, {
paymentMethodId,
gatewayName,
invoiceUrl,
});
return result;
} catch (error) {
this.logger.error(`Failed to create payment SSO token for client ${clientId}`, {
error: getErrorMessage(error),
invoiceId,
paymentMethodId,
gatewayName,
});
throw error;
}
}
/**
* Get products catalog
*/
async getProducts(): Promise<WhmcsCatalogProductNormalized[]> {
try {
const response = await this.connectionService.getCatalogProducts();
return CatalogProviders.Whmcs.transformWhmcsCatalogProductsResponse(response);
} catch (error) {
this.logger.error("Failed to get products", {
error: getErrorMessage(error),
});
throw error;
}
}
/**
* Invalidate payment methods cache for a user
*/
async invalidatePaymentMethodsCache(userId: string): Promise<void> {
try {
await this.cacheService.invalidatePaymentMethods(userId);
this.logger.log(`Invalidated payment methods cache for user ${userId}`);
} catch (error) {
this.logger.error(`Failed to invalidate payment methods cache for user ${userId}`, {
error: getErrorMessage(error),
});
throw error;
}
}
/**
* Normalize WHMCS SSO redirect URLs to absolute using configured base URL.
*/
private resolveRedirectUrl(redirectUrl: string): string {
if (!redirectUrl) return redirectUrl;
const base = this.connectionService.getBaseUrl().replace(/\/+$/, "");
const isAbsolute = /^https?:\/\//i.test(redirectUrl);
if (!isAbsolute) {
const path = redirectUrl.replace(/^\/+/, "");
return `${base}/${path}`;
}
return redirectUrl;
}
/**
* Debug helper: log only the host of the SSO URL (never the token) in non-production.
*/
private debugLogRedirectHost(url: string): void {
if (process.env.NODE_ENV === "production") return;
try {
const target = new URL(url);
const base = new URL(this.connectionService.getBaseUrl());
this.logger.debug("WHMCS Payment SSO redirect host", {
redirectHost: target.host,
redirectOrigin: target.origin,
baseOrigin: base.origin,
});
} catch {
// Ignore parse errors silently
}
}
}