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, Inject } from "@nestjs/common";
|
2025-12-26 14:53:03 +09:00
|
|
|
import * as Providers from "@customer-portal/domain/payments/providers";
|
2025-12-10 16:08:34 +09:00
|
|
|
import type {
|
2025-09-01 15:11:42 +09:00
|
|
|
PaymentMethodList,
|
|
|
|
|
PaymentGateway,
|
|
|
|
|
PaymentGatewayList,
|
|
|
|
|
PaymentMethod,
|
2025-10-03 15:05:46 +09:00
|
|
|
} from "@customer-portal/domain/payments";
|
2025-12-26 14:53:03 +09:00
|
|
|
import type { WhmcsCatalogProductNormalized } from "@customer-portal/domain/services/providers";
|
|
|
|
|
import * as CatalogProviders from "@customer-portal/domain/services/providers";
|
2025-12-10 16:08:34 +09:00
|
|
|
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js";
|
|
|
|
|
import { WhmcsCacheService } from "../cache/whmcs-cache.service.js";
|
2025-12-26 14:53:03 +09:00
|
|
|
import type { WhmcsCreateSsoTokenParams } from "@customer-portal/domain/customer/providers";
|
|
|
|
|
import type { WhmcsGetPayMethodsParams } from "@customer-portal/domain/payments/providers";
|
2025-10-08 13:46:23 +09:00
|
|
|
import type {
|
|
|
|
|
WhmcsPaymentMethod,
|
|
|
|
|
WhmcsPaymentMethodListResponse,
|
|
|
|
|
WhmcsPaymentGateway,
|
|
|
|
|
WhmcsPaymentGatewayListResponse,
|
2025-12-26 14:53:03 +09:00
|
|
|
} from "@customer-portal/domain/payments/providers";
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
@Injectable()
|
|
|
|
|
export class WhmcsPaymentService {
|
|
|
|
|
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-08-22 17:02:49 +09:00
|
|
|
private readonly cacheService: WhmcsCacheService
|
2025-08-20 18:02:50 +09:00
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get payment methods for a client
|
|
|
|
|
*/
|
2025-09-06 13:58:54 +09:00
|
|
|
async getPaymentMethods(
|
|
|
|
|
clientId: number,
|
|
|
|
|
userId: string,
|
|
|
|
|
options?: { fresh?: boolean }
|
|
|
|
|
): Promise<PaymentMethodList> {
|
2025-08-20 18:02:50 +09:00
|
|
|
try {
|
2025-09-06 13:58:54 +09:00
|
|
|
// 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;
|
|
|
|
|
}
|
2025-09-01 15:11:42 +09:00
|
|
|
}
|
2025-08-20 18:02:50 +09:00
|
|
|
|
2025-09-17 18:43:43 +09:00
|
|
|
// Fetch pay methods (use the documented WHMCS structure)
|
2025-10-22 10:58:16 +09:00
|
|
|
const params: WhmcsGetPayMethodsParams = {
|
2025-09-17 18:43:43 +09:00
|
|
|
clientid: clientId,
|
2025-10-22 10:58:16 +09:00
|
|
|
};
|
|
|
|
|
const response: WhmcsPaymentMethodListResponse =
|
|
|
|
|
await this.connectionService.getPaymentMethods(params);
|
2025-08-30 15:10:24 +09:00
|
|
|
|
2025-10-02 18:35:26 +09:00
|
|
|
const paymentMethodsArray: WhmcsPaymentMethod[] = Array.isArray(response.paymethods)
|
2025-09-25 11:44:10 +09:00
|
|
|
? response.paymethods
|
2025-09-17 18:43:43 +09:00
|
|
|
: [];
|
2025-08-30 15:41:08 +09:00
|
|
|
|
2025-09-17 18:43:43 +09:00
|
|
|
let methods = paymentMethodsArray
|
2025-09-01 15:11:42 +09:00
|
|
|
.map((pm: WhmcsPaymentMethod) => {
|
|
|
|
|
try {
|
2025-10-08 13:03:31 +09:00
|
|
|
return Providers.Whmcs.transformWhmcsPaymentMethod(pm);
|
2025-09-01 15:11:42 +09:00
|
|
|
} catch (error) {
|
|
|
|
|
this.logger.error(`Failed to transform payment method`, {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
clientId,
|
|
|
|
|
userId,
|
|
|
|
|
});
|
|
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.filter((method): method is PaymentMethod => method !== null);
|
|
|
|
|
|
2025-09-17 18:43:43 +09:00
|
|
|
// Mark the first method as default (per product decision)
|
2025-10-02 18:35:26 +09:00
|
|
|
if (methods.length > 0) {
|
|
|
|
|
methods = methods.map((m, index) => ({ ...m, isDefault: index === 0 }));
|
|
|
|
|
}
|
2025-09-17 18:43:43 +09:00
|
|
|
|
2025-08-23 17:24:37 +09:00
|
|
|
const result: PaymentMethodList = { paymentMethods: methods, totalCount: methods.length };
|
2025-09-06 13:58:54 +09:00
|
|
|
if (!options?.fresh) {
|
|
|
|
|
await this.cacheService.setPaymentMethods(userId, result);
|
|
|
|
|
}
|
2025-08-20 18:02:50 +09:00
|
|
|
return result;
|
|
|
|
|
} catch (error) {
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.error(`Failed to fetch payment methods for client ${clientId}`, {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
userId,
|
|
|
|
|
});
|
2025-08-20 18:02:50 +09:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-06 13:58:54 +09:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
/**
|
|
|
|
|
* Get available payment gateways
|
|
|
|
|
*/
|
|
|
|
|
async getPaymentGateways(): Promise<PaymentGatewayList> {
|
|
|
|
|
try {
|
|
|
|
|
// Try cache first
|
|
|
|
|
const cached = await this.cacheService.getPaymentGateways();
|
|
|
|
|
if (cached) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.debug("Cache hit for payment gateways");
|
2025-08-20 18:02:50 +09:00
|
|
|
return cached;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Fetch from WHMCS API
|
2025-10-22 10:58:16 +09:00
|
|
|
const response: WhmcsPaymentGatewayListResponse =
|
|
|
|
|
await this.connectionService.getPaymentGateways();
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
if (!response.gateways?.gateway) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.warn("No payment gateways found");
|
2025-08-20 18:02:50 +09:00
|
|
|
return {
|
|
|
|
|
gateways: [],
|
|
|
|
|
totalCount: 0,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Transform payment gateways
|
2025-08-21 15:24:40 +09:00
|
|
|
const gateways = response.gateways.gateway
|
2025-10-22 10:58:16 +09:00
|
|
|
.map((whmcsGateway: WhmcsPaymentGateway) => {
|
2025-08-21 15:24:40 +09:00
|
|
|
try {
|
2025-10-08 13:03:31 +09:00
|
|
|
return Providers.Whmcs.transformWhmcsPaymentGateway(whmcsGateway);
|
2025-08-21 15:24:40 +09:00
|
|
|
} catch (error) {
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.error(`Failed to transform payment gateway ${whmcsGateway.name}`, {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
2025-08-21 15:24:40 +09:00
|
|
|
return null;
|
|
|
|
|
}
|
|
|
|
|
})
|
|
|
|
|
.filter((gateway): gateway is PaymentGateway => gateway !== null);
|
2025-08-20 18:02:50 +09:00
|
|
|
|
|
|
|
|
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) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error("Failed to fetch payment gateways", {
|
2025-08-20 18:02:50 +09:00
|
|
|
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,
|
2025-08-22 17:02:49 +09:00
|
|
|
gatewayName?: string
|
2025-08-20 18:02:50 +09:00
|
|
|
): 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`;
|
2025-08-21 15:24:40 +09:00
|
|
|
|
2025-08-20 18:02:50 +09:00
|
|
|
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,
|
2025-08-21 15:24:40 +09:00
|
|
|
destination: "sso:custom_redirect",
|
2025-08-20 18:02:50 +09:00
|
|
|
sso_redirect_path: invoiceUrl,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const response = await this.connectionService.createSsoToken(params);
|
|
|
|
|
|
2025-09-06 17:38:42 +09:00
|
|
|
const url = this.resolveRedirectUrl(response.redirect_url);
|
|
|
|
|
this.debugLogRedirectHost(url);
|
2025-08-20 18:02:50 +09:00
|
|
|
const result = {
|
2025-09-06 17:38:42 +09:00
|
|
|
url,
|
|
|
|
|
expiresAt: new Date(Date.now() + 60000).toISOString(), // ~60 seconds (per WHMCS spec)
|
2025-08-20 18:02:50 +09:00
|
|
|
};
|
|
|
|
|
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.log(`Created payment SSO token for client ${clientId}, invoice ${invoiceId}`, {
|
|
|
|
|
paymentMethodId,
|
|
|
|
|
gatewayName,
|
|
|
|
|
invoiceUrl,
|
|
|
|
|
});
|
2025-08-20 18:02:50 +09:00
|
|
|
return result;
|
|
|
|
|
} catch (error) {
|
2025-08-22 17:02:49 +09:00
|
|
|
this.logger.error(`Failed to create payment SSO token for client ${clientId}`, {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
invoiceId,
|
|
|
|
|
paymentMethodId,
|
|
|
|
|
gatewayName,
|
|
|
|
|
});
|
2025-08-20 18:02:50 +09:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* Get products catalog
|
|
|
|
|
*/
|
2025-10-29 18:59:17 +09:00
|
|
|
async getProducts(): Promise<WhmcsCatalogProductNormalized[]> {
|
2025-08-20 18:02:50 +09:00
|
|
|
try {
|
2025-09-25 16:23:24 +09:00
|
|
|
const response = await this.connectionService.getCatalogProducts();
|
2025-10-29 18:59:17 +09:00
|
|
|
return CatalogProviders.Whmcs.transformWhmcsCatalogProductsResponse(response);
|
2025-08-20 18:02:50 +09:00
|
|
|
} catch (error) {
|
2025-08-21 15:24:40 +09:00
|
|
|
this.logger.error("Failed to get products", {
|
|
|
|
|
error: getErrorMessage(error),
|
|
|
|
|
});
|
2025-08-20 18:02:50 +09:00
|
|
|
throw error;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-08-30 15:10:24 +09:00
|
|
|
/**
|
|
|
|
|
* 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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2025-09-06 17:38:42 +09:00
|
|
|
/**
|
|
|
|
|
* 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
|
|
|
|
|
}
|
|
|
|
|
}
|
2025-08-20 18:02:50 +09:00
|
|
|
}
|