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.
This commit is contained in:
barsa 2025-11-05 18:17:59 +09:00
parent e1f3160145
commit b3e3689315
16 changed files with 336 additions and 210 deletions

View File

@ -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,
};
}

View File

@ -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,
});
}
// ==========================================

View File

@ -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()),
};
}

View File

@ -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

View File

@ -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;

View File

@ -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<string, unknown>;
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<WhmcsOrderResult> {
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<string, unknown>;
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<Record<string, unknown> | null> {
try {
// Note: The HTTP client throws errors automatically if result === "error"
const response = (await this.connection.getOrders({
id: orderId.toString(),
})) as Record<string, unknown>;
if (response.result !== "success") {
throw new WhmcsOperationException(
`WHMCS GetOrders failed: ${(response.message as string) || "Unknown error"}`,
{ orderId }
);
}
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),
errorType: error?.constructor?.name,
orderId,
});
throw error;

View File

@ -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 (
<RouteLoading
icon={<ClipboardDocumentCheckIcon />}
title="Order Details"
description="Order status and items"
description="Loading order details..."
mode="content"
>
<div className="space-y-4">
<LoadingCard />
<LoadingCard />
<div className="mb-6">
<Button
as="a"
href="/orders"
size="sm"
variant="ghost"
leftIcon={<ArrowLeftIcon className="h-4 w-4" />}
className="text-gray-600 hover:text-gray-900"
>
Back to orders
</Button>
</div>
<div className="animate-pulse">
<div className="rounded-3xl border border-slate-200 bg-white shadow-sm">
{/* Header Section */}
<div className="border-b border-slate-200 bg-gradient-to-br from-white to-slate-50 px-6 py-6 sm:px-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
{/* Left: Title & Status */}
<div className="flex flex-col gap-2">
<div className="flex items-center gap-3">
<div className="h-8 w-48 rounded bg-slate-200" />
<div className="h-6 w-24 rounded-full bg-slate-200" />
</div>
<div className="h-4 w-64 rounded bg-slate-100" />
</div>
{/* Right: Pricing Section */}
<div className="flex items-start gap-6 sm:gap-8">
<div className="text-right space-y-2">
<div className="h-3 w-16 rounded bg-slate-100" />
<div className="h-9 w-24 rounded bg-slate-200" />
</div>
<div className="text-right space-y-2">
<div className="h-3 w-16 rounded bg-slate-100" />
<div className="h-9 w-24 rounded bg-slate-200" />
</div>
</div>
</div>
</div>
{/* Body Section */}
<div className="px-6 py-6 sm:px-8">
<div className="flex flex-col gap-6">
{/* Order Items Section */}
<div>
<div className="mb-2 h-3 w-32 rounded bg-slate-200" />
<div className="space-y-4">
{/* Item 1 */}
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-start gap-3">
<div className="h-6 w-6 rounded-lg bg-slate-100 flex-shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="h-5 w-3/4 rounded bg-slate-200" />
<div className="h-3 w-24 rounded bg-slate-100" />
</div>
<div className="flex flex-col items-end gap-1">
<div className="h-6 w-28 rounded bg-slate-200" />
</div>
</div>
</div>
</div>
{/* Item 2 */}
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-start gap-3">
<div className="h-6 w-6 rounded-lg bg-slate-100 flex-shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="h-5 w-2/3 rounded bg-slate-200" />
<div className="h-3 w-20 rounded bg-slate-100" />
</div>
<div className="flex flex-col items-end gap-1">
<div className="h-6 w-24 rounded bg-slate-200" />
</div>
</div>
</div>
</div>
{/* Item 3 */}
<div className="flex flex-col gap-3 rounded-xl border border-slate-200 bg-white px-4 py-4 sm:flex-row sm:items-center sm:justify-between">
<div className="flex flex-1 items-start gap-3">
<div className="h-6 w-6 rounded-lg bg-slate-100 flex-shrink-0" />
<div className="flex flex-1 items-baseline justify-between gap-4">
<div className="min-w-0 flex-1 space-y-2">
<div className="h-5 w-1/2 rounded bg-slate-200" />
<div className="h-3 w-16 rounded bg-slate-100" />
</div>
<div className="flex flex-col items-end gap-1">
<div className="h-6 w-20 rounded bg-slate-200" />
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
</RouteLoading>
);

View File

@ -10,8 +10,8 @@ export default function OrdersLoading() {
description="View and track all your orders"
mode="content"
>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{Array.from({ length: 6 }).map((_, idx) => (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, idx) => (
<OrderCardSkeleton key={idx} />
))}
</div>

View File

@ -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 */}
<div className="border-b border-slate-100 bg-gradient-to-br from-white to-slate-50 px-6 py-4">
<div className="flex items-center justify-between gap-6">
<div className="flex items-center gap-3">
<div className="px-6 py-4">
<div className="flex items-start justify-between gap-6">
{/* Left section: Icon + Service info + Status */}
<div className="flex items-start gap-3 flex-1 min-w-0">
<div
className={cn(
"flex h-10 w-10 flex-shrink-0 items-center justify-center rounded-lg",
@ -130,19 +125,34 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
>
{serviceIcon}
</div>
<div>
<h3 className="font-semibold text-gray-900">{serviceSummary}</h3>
<div className="mt-0.5 flex items-center gap-2 text-xs text-gray-500">
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2 flex-wrap">
<h3 className="font-semibold text-gray-900">{serviceName}</h3>
<StatusPill label={statusDescriptor.label} variant={statusVariant} />
</div>
<div className="mt-1 flex items-center gap-2 text-xs text-gray-500">
<span className="font-medium">#{order.orderNumber || String(order.id).slice(-8)}</span>
<span></span>
<span>{formattedCreatedDate || "—"}</span>
</div>
{displayItems.length > 0 && (
<div className="mt-3 flex flex-wrap gap-2">
{displayItems.map(item => (
<span
key={item.id}
className="inline-flex items-center rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700"
>
{item.name}
</span>
))}
</div>
)}
</div>
</div>
{/* Pricing */}
{/* Right section: Pricing */}
{showPricing && (
<div className="flex items-center gap-4">
<div className="flex items-start gap-4 flex-shrink-0">
{totals.monthlyTotal > 0 && (
<div className="text-right">
<p className="text-[10px] font-medium uppercase tracking-wider text-blue-600">Monthly</p>
@ -163,41 +173,6 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
)}
</div>
</div>
{/* Body */}
<div className="px-6 py-4">
<div className="flex items-center justify-between gap-4">
<p className="text-sm text-gray-700">{statusDescriptor.description}</p>
<StatusPill label={statusDescriptor.label} variant={statusVariant} />
</div>
{displayItems.length > 1 && (
<div className="mt-3 flex flex-wrap gap-2">
{displayItems.slice(1, 4).map(item => (
<span
key={item.id}
className="inline-flex items-center rounded-md bg-slate-100 px-2.5 py-1 text-xs font-medium text-slate-700"
>
{item.name}
</span>
))}
{displayItems.length > 4 && (
<span className="inline-flex items-center text-xs text-gray-400">
+{displayItems.length - 4} more
</span>
)}
</div>
)}
</div>
{/* Footer */}
{isInteractive && (
<div className="border-t border-slate-100 bg-slate-50/30 px-6 py-3">
<span className="flex items-center gap-2 text-sm font-medium text-blue-600">
View details
<ChevronRightIcon className="h-4 w-4 transition-transform duration-200 group-hover:translate-x-1" />
</span>
</div>
)}
</article>
);
}

View File

@ -2,28 +2,41 @@
export function OrderCardSkeleton() {
return (
<div className="relative overflow-hidden rounded-3xl border border-slate-200 bg-white px-6 pb-6 pt-7 shadow-sm">
<span className="pointer-events-none absolute inset-x-6 top-6 h-1 rounded-full bg-slate-200" aria-hidden />
<div className="animate-pulse space-y-6">
<div className="flex flex-col gap-5 sm:flex-row sm:items-start sm:justify-between">
<div className="flex flex-1 items-start gap-4">
<div className="h-12 w-12 rounded-xl bg-slate-100" />
<div className="flex-1 space-y-3">
<div className="h-6 w-3/4 rounded bg-slate-200" />
<div className="h-3 w-5/6 rounded bg-slate-200" />
<div className="overflow-hidden rounded-2xl border border-slate-200 bg-white shadow-sm">
<div className="px-6 py-4">
<div className="animate-pulse flex items-start justify-between gap-6">
{/* Left section: Icon + Service info + Status */}
<div className="flex items-start gap-3 flex-1 min-w-0">
<div className="h-10 w-10 rounded-lg bg-slate-100 flex-shrink-0" />
<div className="flex-1 min-w-0 space-y-3">
{/* Service name + Status pill inline */}
<div className="flex items-center gap-2 flex-wrap">
<div className="h-5 w-32 rounded bg-slate-200" />
<div className="h-5 w-20 rounded-full bg-slate-200" />
</div>
{/* Order number and date */}
<div className="h-3 w-40 rounded bg-slate-100" />
{/* Display items */}
<div className="flex flex-wrap gap-2">
<div className="h-4 w-24 rounded-full bg-slate-200" />
<div className="h-4 w-20 rounded-full bg-slate-200" />
<div className="h-4 w-16 rounded-full bg-slate-100" />
<div className="h-6 w-24 rounded-md bg-slate-100" />
<div className="h-6 w-20 rounded-md bg-slate-100" />
<div className="h-6 w-16 rounded-md bg-slate-50" />
</div>
</div>
</div>
<div className="h-6 w-24 rounded-full bg-slate-200" />
{/* Right section: Pricing */}
<div className="flex items-start gap-4 flex-shrink-0">
<div className="text-right space-y-1">
<div className="h-2 w-16 rounded bg-slate-100" />
<div className="h-6 w-20 rounded bg-slate-200" />
</div>
</div>
</div>
<div className="h-4 w-32 rounded bg-slate-200" />
</div>
</div>
);
}
export default OrderCardSkeleton;

View File

@ -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(

View File

@ -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 (
<div key={item.id}>
{showBundleStart && (
<div className="mb-2 mt-3 flex items-center gap-2 px-1">
<span className="text-[11px] font-medium uppercase tracking-wider text-purple-600">
Bundled
</span>
<div className="h-px flex-1 bg-purple-200"></div>
</div>
)}
<div
className={cn(
"flex flex-col gap-3 rounded-xl border px-4 py-4 sm:flex-row sm:items-center sm:justify-between",
style.container,
!showBundleStart && itemIndex > 0 && "border-t-0 rounded-t-none"
itemIndex > 0 && "border-t-0 rounded-t-none"
)}
>
{/* Icon + Title & Category | Price */}

View File

@ -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() {
</AlertBanner>
)}
{isLoading ? (
{isLoading || !orders ? (
<div className="space-y-4">
{Array.from({ length: 4 }).map((_, idx) => (
<OrderCardSkeleton key={idx} />

View File

@ -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);
}

View File

@ -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[] = [];

View File

@ -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";