From b3e3689315e13ed82d6ff8f6144f32ad197fba7e Mon Sep 17 00:00:00 2001 From: barsa Date: Wed, 5 Nov 2025 18:17:59 +0900 Subject: [PATCH] Refactor WHMCS integration to remove admin authentication and enhance API request handling - Removed admin authentication methods and related configurations from WhmcsConfigService, simplifying the API integration. - Updated WHMCS API methods to directly use API key authentication, improving security and reducing complexity. - Enhanced error logging in WHMCS HTTP client to include full response context for better debugging. - Adjusted order processing components to reflect changes in authentication handling, ensuring consistent behavior across the application. - Improved loading states in the portal UI for better user experience during order processing. --- .../connection/config/whmcs-config.service.ts | 33 ------ .../services/whmcs-api-methods.service.ts | 40 +++---- .../whmcs-connection-orchestrator.service.ts | 1 - .../services/whmcs-http-client.service.ts | 24 ++-- .../connection/types/connection.types.ts | 4 - .../whmcs/services/whmcs-order.service.ts | 78 +++++++++---- .../(authenticated)/orders/[id]/loading.tsx | 109 +++++++++++++++++- .../app/(authenticated)/orders/loading.tsx | 4 +- .../features/orders/components/OrderCard.tsx | 81 +++++-------- .../orders/components/OrderCardSkeleton.tsx | 41 ++++--- .../features/orders/utils/order-display.ts | 18 +-- .../src/features/orders/views/OrderDetail.tsx | 28 +---- .../src/features/orders/views/OrdersList.tsx | 4 +- .../orders/providers/salesforce/mapper.ts | 32 ++++- .../domain/orders/providers/whmcs/mapper.ts | 31 +++++ .../orders/providers/whmcs/raw.types.ts | 18 ++- 16 files changed, 336 insertions(+), 210 deletions(-) diff --git a/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts b/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts index 1915d112..296f6dfe 100644 --- a/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/config/whmcs-config.service.ts @@ -37,26 +37,6 @@ export class WhmcsConfigService { return this.config.baseUrl; } - /** - * Check if admin authentication is available - */ - hasAdminAuth(): boolean { - return Boolean(this.config.adminUsername && this.config.adminPasswordHash); - } - - /** - * Get admin authentication credentials - */ - getAdminAuth(): { username: string; passwordHash: string } | null { - if (!this.hasAdminAuth()) { - return null; - } - return { - username: this.config.adminUsername!, - passwordHash: this.config.adminPasswordHash!, - }; - } - /** * Validate that required configuration is present */ @@ -91,17 +71,6 @@ export class WhmcsConfigService { const secret = this.getFirst([isDev ? "WHMCS_DEV_API_SECRET" : undefined, "WHMCS_API_SECRET"]) || ""; - const adminUsername = this.getFirst([ - isDev ? "WHMCS_DEV_ADMIN_USERNAME" : undefined, - "WHMCS_ADMIN_USERNAME", - ]); - - const adminPasswordHash = this.getFirst([ - isDev ? "WHMCS_DEV_ADMIN_PASSWORD_MD5" : undefined, - "WHMCS_ADMIN_PASSWORD_MD5", - "WHMCS_ADMIN_PASSWORD_HASH", - ]); - return { baseUrl, identifier, @@ -109,8 +78,6 @@ export class WhmcsConfigService { timeout: this.getNumberConfig("WHMCS_API_TIMEOUT", 30000), retryAttempts: this.getNumberConfig("WHMCS_API_RETRY_ATTEMPTS", 3), retryDelay: this.getNumberConfig("WHMCS_API_RETRY_DELAY", 1000), - adminUsername, - adminPasswordHash, }; } diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts index 77c9317c..13493f0f 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts @@ -177,35 +177,29 @@ export class WhmcsApiMethodsService { } // ========================================== - // ADMIN API METHODS (require admin auth) + // ADMIN API METHODS (require admin permissions on API key) // ========================================== + /** + * Accept and provision an order + * Note: Your API key must have AcceptOrder permission granted + */ async acceptOrder(orderId: number): Promise<{ result: string }> { - if (!this.configService.hasAdminAuth()) { - throw new Error("Admin authentication required for AcceptOrder"); - } - - return this.makeRequest<{ result: string }>( - "AcceptOrder", - { - orderid: orderId.toString(), - autosetup: true, - sendemail: false, - }, - { useAdminAuth: true } - ); + return this.makeRequest<{ result: string }>("AcceptOrder", { + orderid: orderId.toString(), + autosetup: true, + sendemail: false, + }); } + /** + * Cancel an order + * Note: Your API key must have CancelOrder permission granted + */ async cancelOrder(orderId: number): Promise<{ result: string }> { - if (!this.configService.hasAdminAuth()) { - throw new Error("Admin authentication required for CancelOrder"); - } - - return this.makeRequest<{ result: string }>( - "CancelOrder", - { orderid: orderId }, - { useAdminAuth: true } - ); + return this.makeRequest<{ result: string }>("CancelOrder", { + orderid: orderId, + }); } // ========================================== diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts index 22398b8a..bf14b641 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts @@ -334,7 +334,6 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit { timeout: config.timeout, retryAttempts: config.retryAttempts, retryDelay: config.retryDelay, - hasAdminAuth: this.configService.hasAdminAuth(), hasAccessKey: Boolean(this.configService.getAccessKey()), }; } diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts index 7f99ac3b..1bfd3e95 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-http-client.service.ts @@ -165,6 +165,7 @@ export class WhmcsHttpClientService { /** * Build request body for WHMCS API + * Uses API key authentication (identifier + secret) */ private buildRequestBody( config: WhmcsApiConfig, @@ -174,14 +175,9 @@ export class WhmcsHttpClientService { ): string { const formData = new URLSearchParams(); - // Add authentication - if (options.useAdminAuth && config.adminUsername && config.adminPasswordHash) { - formData.append("username", config.adminUsername); - formData.append("password", config.adminPasswordHash); - } else { - formData.append("identifier", config.identifier); - formData.append("secret", config.secret); - } + // Add API key authentication + formData.append("identifier", config.identifier); + formData.append("secret", config.secret); // Add action and response format formData.append("action", action); @@ -275,13 +271,23 @@ export class WhmcsHttpClientService { ); const errorCode = this.toDisplayString(parsedResponse.errorcode, "unknown"); + // Extract all additional fields from the response for debugging + const { result, message, error, errorcode, ...additionalFields } = parsedResponse; + this.logger.error(`WHMCS API returned error [${action}]`, { errorMessage, errorCode, + additionalFields: Object.keys(additionalFields).length > 0 ? additionalFields : undefined, params: this.sanitizeLogParams(params), + fullResponse: parsedResponse, }); - throw new Error(`WHMCS API Error: ${errorMessage} (${errorCode})`); + // Include full context in the error for better debugging + const errorContext = Object.keys(additionalFields).length > 0 + ? ` | Additional details: ${JSON.stringify(additionalFields)}` + : ''; + + throw new Error(`WHMCS API Error: ${errorMessage} (${errorCode})${errorContext}`); } // For successful responses, WHMCS API returns data directly at the root level diff --git a/apps/bff/src/integrations/whmcs/connection/types/connection.types.ts b/apps/bff/src/integrations/whmcs/connection/types/connection.types.ts index 91441367..35fff956 100644 --- a/apps/bff/src/integrations/whmcs/connection/types/connection.types.ts +++ b/apps/bff/src/integrations/whmcs/connection/types/connection.types.ts @@ -5,13 +5,9 @@ export interface WhmcsApiConfig { timeout?: number; retryAttempts?: number; retryDelay?: number; - // Optional elevated admin credentials for privileged actions (eg. AcceptOrder) - adminUsername?: string; - adminPasswordHash?: string; // MD5 hash of admin password } export interface WhmcsRequestOptions { - useAdminAuth?: boolean; timeout?: number; retryAttempts?: number; retryDelay?: number; diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts index 284c14c8..6375b468 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts @@ -25,6 +25,10 @@ export class WhmcsOrderService { /** * Create order in WHMCS using AddOrder API * Maps Salesforce OrderItems to WHMCS products + * + * WHMCS API Response Structure: + * Success: { orderid, productids, serviceids, addonids, domainids, invoiceid } + * Error: Thrown by HTTP client before returning */ async addOrder(params: WhmcsAddOrderParams): Promise<{ orderId: number }> { this.logger.log("Creating WHMCS order", { @@ -38,18 +42,40 @@ export class WhmcsOrderService { // Build WHMCS AddOrder payload const addOrderPayload = this.buildAddOrderPayload(params); + this.logger.debug("Built WHMCS AddOrder payload", { + clientId: params.clientId, + productCount: Array.isArray(addOrderPayload.pid) ? addOrderPayload.pid.length : 0, + pids: addOrderPayload.pid, + quantities: addOrderPayload.qty, // CRITICAL: Must be included for products to be added + billingCycles: addOrderPayload.billingcycle, + hasConfigOptions: Boolean(addOrderPayload.configoptions), + hasCustomFields: Boolean(addOrderPayload.customfields), + promoCode: addOrderPayload.promocode, + paymentMethod: addOrderPayload.paymentmethod, + }); + // Call WHMCS AddOrder API + // Note: The HTTP client throws errors automatically if result === "error" + // So we only get here if the request was successful const response = (await this.connection.addOrder(addOrderPayload)) as Record; - if (response.result !== "success") { - throw new WhmcsOperationException( - `WHMCS AddOrder failed: ${(response.message as string) || "Unknown error"}`, - { params } - ); - } + // Log the full response for debugging + this.logger.debug("WHMCS AddOrder response", { + response, + clientId: params.clientId, + sfOrderId: params.sfOrderId, + }); + // Extract order ID from response const orderId = parseInt(response.orderid as string, 10); - if (!orderId) { + if (!orderId || isNaN(orderId)) { + this.logger.error("WHMCS AddOrder returned invalid order ID", { + response, + orderidValue: response.orderid, + orderidType: typeof response.orderid, + clientId: params.clientId, + sfOrderId: params.sfOrderId, + }); throw new WhmcsOperationException("WHMCS AddOrder did not return valid order ID", { response, }); @@ -57,16 +83,23 @@ export class WhmcsOrderService { this.logger.log("WHMCS order created successfully", { orderId, + invoiceId: response.invoiceid, + serviceIds: response.serviceids, clientId: params.clientId, sfOrderId: params.sfOrderId, }); return { orderId }; } catch (error) { + // Enhanced error logging with full context this.logger.error("Failed to create WHMCS order", { error: getErrorMessage(error), + errorType: error?.constructor?.name, clientId: params.clientId, sfOrderId: params.sfOrderId, + itemCount: params.items.length, + // Include first 100 chars of error stack for debugging + errorStack: error instanceof Error ? error.stack?.substring(0, 100) : undefined, }); throw error; } @@ -75,6 +108,10 @@ export class WhmcsOrderService { /** * Accept/provision order in WHMCS using AcceptOrder API * This activates services and creates subscriptions + * + * WHMCS API Response Structure: + * Success: { orderid, invoiceid, serviceids, addonids, domainids } + * Error: Thrown by HTTP client before returning */ async acceptOrder(orderId: number, sfOrderId?: string): Promise { this.logger.log("Accepting WHMCS order", { @@ -84,14 +121,16 @@ export class WhmcsOrderService { try { // Call WHMCS AcceptOrder API + // Note: The HTTP client throws errors automatically if result === "error" + // So we only get here if the request was successful const response = (await this.connection.acceptOrder(orderId)) as Record; - if (response.result !== "success") { - throw new WhmcsOperationException( - `WHMCS AcceptOrder failed: ${(response.message as string) || "Unknown error"}`, - { orderId, sfOrderId } - ); - } + // Log the full response for debugging + this.logger.debug("WHMCS AcceptOrder response", { + response, + orderId, + sfOrderId, + }); // Extract service IDs from response const serviceIds: number[] = []; @@ -116,10 +155,14 @@ export class WhmcsOrderService { return result; } catch (error) { + // Enhanced error logging with full context this.logger.error("Failed to accept WHMCS order", { error: getErrorMessage(error), + errorType: error?.constructor?.name, orderId, sfOrderId, + // Include first 100 chars of error stack for debugging + errorStack: error instanceof Error ? error.stack?.substring(0, 100) : undefined, }); throw error; } @@ -130,21 +173,16 @@ export class WhmcsOrderService { */ async getOrderDetails(orderId: number): Promise | null> { try { + // Note: The HTTP client throws errors automatically if result === "error" const response = (await this.connection.getOrders({ id: orderId.toString(), })) as Record; - if (response.result !== "success") { - throw new WhmcsOperationException( - `WHMCS GetOrders failed: ${(response.message as string) || "Unknown error"}`, - { orderId } - ); - } - return (response.orders as { order?: Record[] })?.order?.[0] || null; } catch (error) { this.logger.error("Failed to get WHMCS order details", { error: getErrorMessage(error), + errorType: error?.constructor?.name, orderId, }); throw error; diff --git a/apps/portal/src/app/(authenticated)/orders/[id]/loading.tsx b/apps/portal/src/app/(authenticated)/orders/[id]/loading.tsx index ed4565f6..9645aded 100644 --- a/apps/portal/src/app/(authenticated)/orders/[id]/loading.tsx +++ b/apps/portal/src/app/(authenticated)/orders/[id]/loading.tsx @@ -1,18 +1,115 @@ import { RouteLoading } from "@/components/molecules/RouteLoading"; -import { ClipboardDocumentCheckIcon } from "@heroicons/react/24/outline"; -import { LoadingCard } from "@/components/atoms/loading-skeleton"; +import { ClipboardDocumentCheckIcon, ArrowLeftIcon } from "@heroicons/react/24/outline"; +import { Button } from "@/components/atoms/button"; export default function OrderDetailLoading() { return ( } title="Order Details" - description="Order status and items" + description="Loading order details..." mode="content" > -
- - +
+ +
+ +
+
+ {/* Header Section */} +
+
+ {/* Left: Title & Status */} +
+
+
+
+
+
+
+ + {/* Right: Pricing Section */} +
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Body Section */} +
+
+ {/* Order Items Section */} +
+
+
+ {/* Item 1 */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Item 2 */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+ + {/* Item 3 */} +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
); diff --git a/apps/portal/src/app/(authenticated)/orders/loading.tsx b/apps/portal/src/app/(authenticated)/orders/loading.tsx index aa8142e4..5fadc1da 100644 --- a/apps/portal/src/app/(authenticated)/orders/loading.tsx +++ b/apps/portal/src/app/(authenticated)/orders/loading.tsx @@ -10,8 +10,8 @@ export default function OrdersLoading() { description="View and track all your orders" mode="content" > -
- {Array.from({ length: 6 }).map((_, idx) => ( +
+ {Array.from({ length: 4 }).map((_, idx) => ( ))}
diff --git a/apps/portal/src/features/orders/components/OrderCard.tsx b/apps/portal/src/features/orders/components/OrderCard.tsx index 0c0bc002..7bb79291 100644 --- a/apps/portal/src/features/orders/components/OrderCard.tsx +++ b/apps/portal/src/features/orders/components/OrderCard.tsx @@ -7,7 +7,6 @@ import { DevicePhoneMobileIcon, LockClosedIcon, CubeIcon, - ChevronRightIcon, } from "@heroicons/react/24/outline"; import { calculateOrderTotals, @@ -71,14 +70,10 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps) () => buildOrderDisplayItems(order.itemsSummary), [order.itemsSummary] ); - const serviceSummary = useMemo( - () => - summarizeOrderDisplayItems( - displayItems, - order.itemSummary || "Service package" - ), - [displayItems, order.itemSummary] - ); + + // Use just the order type as the service name + const serviceName = order.orderType ? `${order.orderType} Service` : "Service Order"; + const totals = calculateOrderTotals(order.itemsSummary, order.totalAmount); const createdDate = useMemo(() => { if (!order.createdDate) return null; @@ -118,10 +113,10 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps) role={isInteractive ? "button" : undefined} tabIndex={isInteractive ? 0 : undefined} > - {/* Header */} -
-
-
+
+
+ {/* Left section: Icon + Service info + Status */} +
{serviceIcon}
-
-

{serviceSummary}

-
+
+
+

{serviceName}

+ +
+
#{order.orderNumber || String(order.id).slice(-8)} {formattedCreatedDate || "—"}
+ {displayItems.length > 0 && ( +
+ {displayItems.map(item => ( + + {item.name} + + ))} +
+ )}
- {/* Pricing */} + {/* Right section: Pricing */} {showPricing && ( -
+
{totals.monthlyTotal > 0 && (

Monthly

@@ -163,41 +173,6 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps) )}
- - {/* Body */} -
-
-

{statusDescriptor.description}

- -
- {displayItems.length > 1 && ( -
- {displayItems.slice(1, 4).map(item => ( - - {item.name} - - ))} - {displayItems.length > 4 && ( - - +{displayItems.length - 4} more - - )} -
- )} -
- - {/* Footer */} - {isInteractive && ( -
- - View details - - -
- )} ); } diff --git a/apps/portal/src/features/orders/components/OrderCardSkeleton.tsx b/apps/portal/src/features/orders/components/OrderCardSkeleton.tsx index 8fdbb455..406b9545 100644 --- a/apps/portal/src/features/orders/components/OrderCardSkeleton.tsx +++ b/apps/portal/src/features/orders/components/OrderCardSkeleton.tsx @@ -2,28 +2,41 @@ export function OrderCardSkeleton() { return ( -
- -
-
-
-
-
-
-
+
+
+
+ {/* Left section: Icon + Service info + Status */} +
+
+
+ {/* Service name + Status pill inline */} +
+
+
+
+ {/* Order number and date */} +
+ {/* Display items */}
-
-
-
+
+
+
-
+ + {/* Right section: Pricing */} +
+
+
+
+
+
-
); } export default OrderCardSkeleton; + diff --git a/apps/portal/src/features/orders/utils/order-display.ts b/apps/portal/src/features/orders/utils/order-display.ts index cd8244f6..618e1d1f 100644 --- a/apps/portal/src/features/orders/utils/order-display.ts +++ b/apps/portal/src/features/orders/utils/order-display.ts @@ -199,8 +199,8 @@ export function buildOrderDisplayItems( return []; } - // Don't group items - show each one separately - const displayItems = items.map((item, index) => { + // Map items to display format - keep the order from the backend + return items.map((item, index) => { const charges = aggregateCharges({ indices: [index], items: [item] }); const isBundled = Boolean(item.isBundledAddon); @@ -217,20 +217,6 @@ export function buildOrderDisplayItems( isBundle: isBundled, }; }); - - // Sort: monthly subscriptions first, then one-time items - return displayItems.sort((a, b) => { - // Get the primary charge kind for each item - const aChargeKind = a.charges[0]?.kind ?? "other"; - const bChargeKind = b.charges[0]?.kind ?? "other"; - - // Sort by charge kind (monthly first, then one-time) - const orderDiff = CHARGE_ORDER[aChargeKind] - CHARGE_ORDER[bChargeKind]; - if (orderDiff !== 0) return orderDiff; - - // If same charge kind, maintain original order - return 0; - }); } export function summarizeOrderDisplayItems( diff --git a/apps/portal/src/features/orders/views/OrderDetail.tsx b/apps/portal/src/features/orders/views/OrderDetail.tsx index fda0e90d..38fb9988 100644 --- a/apps/portal/src/features/orders/views/OrderDetail.tsx +++ b/apps/portal/src/features/orders/views/OrderDetail.tsx @@ -105,12 +105,12 @@ const ITEM_VISUAL_STYLES: Record< icon: "bg-green-50 text-green-600", }, addon: { - container: "border-slate-200 bg-white", - icon: "bg-slate-50 text-slate-600", + container: "border-purple-200 bg-white", + icon: "bg-purple-50 text-purple-600", }, activation: { - container: "border-slate-200 bg-white", - icon: "bg-slate-50 text-slate-600", + container: "border-green-200 bg-white", + icon: "bg-green-50 text-green-600", }, other: { container: "border-slate-200 bg-white", @@ -118,15 +118,7 @@ const ITEM_VISUAL_STYLES: Record< }, }; -const BUNDLE_VISUAL_STYLE = { - container: "border-purple-200 bg-white", - icon: "bg-purple-50 text-purple-600", -}; - const getItemVisualStyle = (item: OrderDisplayItem) => { - if (item.isBundle) { - return BUNDLE_VISUAL_STYLE; - } return ITEM_VISUAL_STYLES[item.primaryCategory] ?? ITEM_VISUAL_STYLES.other; }; @@ -379,25 +371,15 @@ export function OrderDetailContainer() { displayItems.map((item, itemIndex) => { const categoryConfig = CATEGORY_CONFIG[item.primaryCategory] ?? CATEGORY_CONFIG.other; const Icon = categoryConfig.icon; - const prevItem = itemIndex > 0 ? displayItems[itemIndex - 1] : null; - const showBundleStart = item.isBundle && (!prevItem || !prevItem.isBundle); const style = getItemVisualStyle(item); return (
- {showBundleStart && ( -
- - Bundled - -
-
- )}
0 && "border-t-0 rounded-t-none" + itemIndex > 0 && "border-t-0 rounded-t-none" )} > {/* Icon + Title & Category | Price */} diff --git a/apps/portal/src/features/orders/views/OrdersList.tsx b/apps/portal/src/features/orders/views/OrdersList.tsx index 7d47d1ed..3b476a8d 100644 --- a/apps/portal/src/features/orders/views/OrdersList.tsx +++ b/apps/portal/src/features/orders/views/OrdersList.tsx @@ -36,7 +36,7 @@ function OrdersSuccessBanner() { export function OrdersListContainer() { const router = useRouter(); - const { data: orders = [], isLoading, isError, error, refetch, isFetching } = useOrdersList(); + const { data: orders, isLoading, isError, error, refetch, isFetching } = useOrdersList(); const { errorMessage, showRetry } = useMemo(() => { if (!isError) { @@ -86,7 +86,7 @@ export function OrdersListContainer() { )} - {isLoading ? ( + {isLoading || !orders ? (
{Array.from({ length: 4 }).map((_, idx) => ( diff --git a/packages/domain/orders/providers/salesforce/mapper.ts b/packages/domain/orders/providers/salesforce/mapper.ts index 6f21a10a..61bdedad 100644 --- a/packages/domain/orders/providers/salesforce/mapper.ts +++ b/packages/domain/orders/providers/salesforce/mapper.ts @@ -25,6 +25,18 @@ import type { SalesforceOrderRecord, } from "./raw.types"; +/** + * Helper function to get sort priority for item class + */ +function getItemClassSortPriority(itemClass?: string): number { + if (!itemClass) return 4; + const normalized = itemClass.toLowerCase(); + if (normalized === 'service') return 1; + if (normalized === 'installation' || normalized === 'activation') return 2; + if (normalized === 'add-on') return 3; + return 4; +} + /** * Transform a Salesforce OrderItem record into domain details + summary. */ @@ -103,6 +115,13 @@ export function transformSalesforceOrderDetails( transformSalesforceOrderItem(record, fieldMap) ); + // Sort items by item class priority (Service -> Installation/Activation -> Add-on -> Others) + transformedItems.sort((a, b) => { + const priorityA = getItemClassSortPriority(a.summary.itemClass); + const priorityB = getItemClassSortPriority(b.summary.itemClass); + return priorityA - priorityB; + }); + const items = transformedItems.map(item => item.details); const itemsSummary = transformedItems.map(item => item.summary); @@ -132,9 +151,18 @@ export function transformSalesforceOrderSummary( itemRecords: SalesforceOrderItemRecord[], fieldMap: SalesforceOrderFieldMap = defaultSalesforceOrderFieldMap ): OrderSummary { - const itemsSummary = itemRecords.map(record => - transformSalesforceOrderItem(record, fieldMap).summary + const transformedItems = itemRecords.map(record => + transformSalesforceOrderItem(record, fieldMap) ); + + // Sort items by item class priority (Service -> Installation/Activation -> Add-on -> Others) + transformedItems.sort((a, b) => { + const priorityA = getItemClassSortPriority(a.summary.itemClass); + const priorityB = getItemClassSortPriority(b.summary.itemClass); + return priorityA - priorityB; + }); + + const itemsSummary = transformedItems.map(item => item.summary); return buildOrderSummary(order, itemsSummary, fieldMap); } diff --git a/packages/domain/orders/providers/whmcs/mapper.ts b/packages/domain/orders/providers/whmcs/mapper.ts index 1eb5b4f1..ebbd5d8d 100644 --- a/packages/domain/orders/providers/whmcs/mapper.ts +++ b/packages/domain/orders/providers/whmcs/mapper.ts @@ -80,6 +80,37 @@ export function mapOrderToWhmcsItems( /** * Build WHMCS AddOrder API payload from parameters * Converts structured params into WHMCS API array format + * + * WHMCS AddOrder API Documentation: + * @see https://developers.whmcs.com/api-reference/addorder/ + * + * Required Parameters: + * - clientid (int): The client ID + * - paymentmethod (string): Payment method (e.g. "stripe", "paypal", "mailin") + * - pid (int[]): Array of product IDs + * - qty (int[]): Array of product quantities (REQUIRED! Without this, no products are added) + * - billingcycle (string[]): Array of billing cycles (e.g. "monthly", "onetime") + * + * Optional Parameters: + * - promocode (string): Promotion code to apply + * - noinvoice (bool): Don't create invoice + * - noinvoiceemail (bool): Don't send invoice email + * - noemail (bool): Don't send order confirmation email + * - configoptions (string[]): Base64 encoded serialized arrays of config options + * - customfields (string[]): Base64 encoded serialized arrays of custom fields + * + * Response Fields: + * - result: "success" or "error" + * - orderid: The created order ID + * - serviceids: Comma-separated service IDs created + * - addonids: Comma-separated addon IDs created + * - domainids: Comma-separated domain IDs created + * - invoiceid: The invoice ID created + * + * Common Errors: + * - "No items added to cart so order cannot proceed" - Missing or invalid qty parameter + * - "Client ID Not Found" - Invalid client ID + * - "Invalid Payment Method" - Payment method not recognized */ export function buildWhmcsAddOrderPayload(params: WhmcsAddOrderParams): WhmcsAddOrderPayload { const pids: string[] = []; diff --git a/packages/domain/orders/providers/whmcs/raw.types.ts b/packages/domain/orders/providers/whmcs/raw.types.ts index 93f0c389..f2f0ad35 100644 --- a/packages/domain/orders/providers/whmcs/raw.types.ts +++ b/packages/domain/orders/providers/whmcs/raw.types.ts @@ -1,8 +1,22 @@ /** * Orders Domain - WHMCS Provider Raw Types * - * Raw types for WHMCS AddOrder API request/response. - * Based on WHMCS API documentation: https://developers.whmcs.com/api-reference/addorder/ + * Raw types for WHMCS API request/response. + * + * WHMCS AddOrder API: + * @see https://developers.whmcs.com/api-reference/addorder/ + * Adds an order to a client. Returns orderid, serviceids, addonids, domainids, invoiceid. + * CRITICAL: Must include qty[] parameter or no products will be added to the order! + * + * WHMCS AcceptOrder API: + * @see https://developers.whmcs.com/api-reference/acceptorder/ + * Accepts a pending order. Parameters: + * - orderid (int): Required - The order ID to accept + * - autosetup (bool): Optional - Send request to product module to activate service + * - sendemail (bool): Optional - Send automatic emails (Product Welcome, etc.) + * - registrar (string): Optional - Specific registrar for domains + * - sendregistrar (bool): Optional - Send request to registrar + * Returns: { result: "success" } on success */ import { z } from "zod";