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 { 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 { 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 { 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 { 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 { 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 } } }