314 lines
9.8 KiB
TypeScript

import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { WhmcsConnectionService } from "./whmcs-connection.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
export interface WhmcsOrderItem {
productId: string; // WHMCS Product ID from Product2.WHMCS_Product_Id__c
billingCycle: string; // monthly, quarterly, annually, onetime
quantity: number;
configOptions?: Record<string, string>;
customFields?: Record<string, string>;
}
export interface WhmcsAddOrderParams {
clientId: number;
items: WhmcsOrderItem[];
paymentMethod: string; // Required by WHMCS API - e.g., "mailin", "paypal"
promoCode?: string;
notes?: string;
sfOrderId?: string; // For tracking back to Salesforce
noinvoice?: boolean; // Default false - create invoice
noinvoiceemail?: boolean; // Default false - suppress invoice email (if invoice is created)
noemail?: boolean; // Default false - send emails
}
export interface WhmcsOrderResult {
orderId: number;
invoiceId?: number;
serviceIds: number[];
}
@Injectable()
export class WhmcsOrderService {
constructor(
private readonly connection: WhmcsConnectionService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Create order in WHMCS using AddOrder API
* Maps Salesforce OrderItems to WHMCS products
*/
async addOrder(params: WhmcsAddOrderParams): Promise<{ orderId: number }> {
this.logger.log("Creating WHMCS order", {
clientId: params.clientId,
itemCount: params.items.length,
sfOrderId: params.sfOrderId,
hasPromoCode: Boolean(params.promoCode),
});
try {
// Build WHMCS AddOrder payload
const addOrderPayload = this.buildAddOrderPayload(params);
// Call WHMCS AddOrder API
const response = (await this.connection.addOrder(addOrderPayload)) as Record<string, unknown>;
if (response.result !== "success") {
throw new Error(
`WHMCS AddOrder failed: ${(response.message as string) || "Unknown error"}`
);
}
const orderId = parseInt(response.orderid as string, 10);
if (!orderId) {
throw new Error("WHMCS AddOrder did not return valid order ID");
}
this.logger.log("WHMCS order created successfully", {
orderId,
clientId: params.clientId,
sfOrderId: params.sfOrderId,
});
return { orderId };
} catch (error) {
this.logger.error("Failed to create WHMCS order", {
error: getErrorMessage(error),
clientId: params.clientId,
sfOrderId: params.sfOrderId,
});
throw error;
}
}
/**
* Accept/provision order in WHMCS using AcceptOrder API
* This activates services and creates subscriptions
*/
async acceptOrder(orderId: number, sfOrderId?: string): Promise<WhmcsOrderResult> {
this.logger.log("Accepting WHMCS order", {
orderId,
sfOrderId,
});
try {
// Call WHMCS AcceptOrder API
const response = (await this.connection.acceptOrder(orderId)) as Record<string, unknown>;
if (response.result !== "success") {
throw new Error(
`WHMCS AcceptOrder failed: ${(response.message as string) || "Unknown error"}`
);
}
// Extract service IDs from response
const serviceIds: number[] = [];
if (response.serviceids) {
// serviceids can be a string of comma-separated IDs
const ids = (response.serviceids as string).toString().split(",");
serviceIds.push(...ids.map((id: string) => parseInt(id.trim(), 10)).filter(Boolean));
}
const result: WhmcsOrderResult = {
orderId,
invoiceId: response.invoiceid ? parseInt(response.invoiceid as string, 10) : undefined,
serviceIds,
};
this.logger.log("WHMCS order accepted successfully", {
orderId,
invoiceId: result.invoiceId,
serviceCount: serviceIds.length,
sfOrderId,
});
return result;
} catch (error) {
this.logger.error("Failed to accept WHMCS order", {
error: getErrorMessage(error),
orderId,
sfOrderId,
});
throw error;
}
}
/**
* Get order details from WHMCS
*/
async getOrderDetails(orderId: number): Promise<Record<string, unknown> | null> {
try {
const response = (await this.connection.getOrders({
id: orderId.toString(),
})) as Record<string, unknown>;
if (response.result !== "success") {
throw new Error(
`WHMCS GetOrders failed: ${(response.message as string) || "Unknown error"}`
);
}
return (response.orders as { order?: Record<string, unknown>[] })?.order?.[0] || null;
} catch (error) {
this.logger.error("Failed to get WHMCS order details", {
error: getErrorMessage(error),
orderId,
});
throw error;
}
}
/**
* Check if client has valid payment method
*/
async hasPaymentMethod(clientId: number): Promise<boolean> {
try {
const response = (await this.connection.getPayMethods({
clientid: clientId,
})) as unknown as Record<string, unknown>;
if (response.result !== "success") {
this.logger.warn("Failed to check payment methods", {
clientId,
error: response.message as string,
});
return false;
}
// Check if client has any payment methods
const paymethodsNode = (response.paymethods as { paymethod?: unknown } | undefined)
?.paymethod;
const totalResults = Number((response as { totalresults?: unknown })?.totalresults ?? 0) || 0;
const methodCount = Array.isArray(paymethodsNode)
? paymethodsNode.length
: paymethodsNode && typeof paymethodsNode === "object"
? 1
: 0;
const hasValidMethod = methodCount > 0 || totalResults > 0;
this.logger.log("Payment method check completed", {
clientId,
hasPaymentMethod: hasValidMethod,
methodCount,
totalResults,
});
return hasValidMethod;
} catch (error) {
this.logger.error("Failed to check payment methods", {
error: getErrorMessage(error),
clientId,
});
// Don't throw - return false to indicate no payment method
return false;
}
}
/**
* Build WHMCS AddOrder payload from our parameters
* Following official WHMCS API documentation format
*/
private buildAddOrderPayload(params: WhmcsAddOrderParams): Record<string, unknown> {
const payload: Record<string, unknown> = {
clientid: params.clientId,
paymentmethod: params.paymentMethod, // Required by WHMCS API
noinvoice: params.noinvoice ? true : false,
// If invoices are created (noinvoice=false), optionally suppress invoice email
...(params.noinvoiceemail ? { noinvoiceemail: true } : {}),
noemail: params.noemail ? true : false,
};
// Add promo code if specified
if (params.promoCode) {
payload.promocode = params.promoCode;
}
// Extract arrays for WHMCS API format
const pids: string[] = [];
const billingCycles: string[] = [];
const quantities: number[] = [];
const configOptions: string[] = [];
const customFields: string[] = [];
params.items.forEach(item => {
pids.push(item.productId);
billingCycles.push(item.billingCycle);
quantities.push(item.quantity);
// Handle config options - WHMCS expects base64 encoded serialized arrays
if (item.configOptions && Object.keys(item.configOptions).length > 0) {
const serialized = this.serializeForWhmcs(item.configOptions);
configOptions.push(serialized);
} else {
configOptions.push(""); // Empty string for items without config options
}
// Handle custom fields - WHMCS expects base64 encoded serialized arrays
if (item.customFields && Object.keys(item.customFields).length > 0) {
const serialized = this.serializeForWhmcs(item.customFields);
customFields.push(serialized);
} else {
customFields.push(""); // Empty string for items without custom fields
}
});
// Set arrays in WHMCS format
payload.pid = pids;
payload.billingcycle = billingCycles;
payload.qty = quantities;
if (configOptions.some(opt => opt !== "")) {
payload.configoptions = configOptions;
}
if (customFields.some(field => field !== "")) {
payload.customfields = customFields;
}
this.logger.debug("Built WHMCS AddOrder payload", {
clientId: params.clientId,
productCount: params.items.length,
pids,
billingCycles,
hasConfigOptions: configOptions.some(opt => opt !== ""),
hasCustomFields: customFields.some(field => field !== ""),
});
return payload;
}
/**
* Serialize data for WHMCS API (base64 encoded serialized array)
*/
private serializeForWhmcs(data: Record<string, string>): string {
try {
// Convert to PHP-style serialized format, then base64 encode
const serialized = this.phpSerialize(data);
return Buffer.from(serialized).toString("base64");
} catch (error) {
this.logger.warn("Failed to serialize data for WHMCS", {
error: getErrorMessage(error),
data,
});
return "";
}
}
/**
* Simple PHP serialize implementation for WHMCS compatibility
* Handles string values only (sufficient for config options and custom fields)
*/
private phpSerialize(data: Record<string, string>): string {
const entries = Object.entries(data);
const serializedEntries = entries.map(([key, value]) => {
// Ensure values are strings and escape quotes
const safeKey = String(key).replace(/"/g, '\\"');
const safeValue = String(value).replace(/"/g, '\\"');
return `s:${safeKey.length}:"${safeKey}";s:${safeValue.length}:"${safeValue}";`;
});
return `a:${entries.length}:{${serializedEntries.join("")}}`;
}
}