314 lines
9.8 KiB
TypeScript
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("")}}`;
|
|
}
|
|
}
|