Refactor WHMCS service and improve address handling

- Updated WHMCS service to utilize addressSchema for client address retrieval, enhancing data validation.
- Cleaned up imports in WHMCS client service for better organization.
- Improved logging format in WHMCS subscription service for clearer output.
- Streamlined address handling in order builder service for consistency.
- Enhanced code readability by formatting JSX elements in ProfileContainer and other components.
- Removed deprecated billing cycle normalization logic and centralized it in the domain helpers for better maintainability.
This commit is contained in:
barsa 2025-11-04 11:14:26 +09:00
parent 0a3d5b1e3c
commit 67691a50b5
23 changed files with 218 additions and 155 deletions

View File

@ -10,10 +10,7 @@ import type {
WhmcsAddClientResponse,
WhmcsValidateLoginResponse,
} from "@customer-portal/domain/customer";
import {
Providers as CustomerProviders,
type WhmcsClient,
} from "@customer-portal/domain/customer";
import { Providers as CustomerProviders, type WhmcsClient } from "@customer-portal/domain/customer";
@Injectable()
export class WhmcsClientService {

View File

@ -85,7 +85,9 @@ export class WhmcsSubscriptionService {
// Cache the result
await this.cacheService.setSubscriptionsList(userId, result);
this.logger.log(`Fetched ${result.subscriptions.length} subscriptions for client ${clientId}`);
this.logger.log(
`Fetched ${result.subscriptions.length} subscriptions for client ${clientId}`
);
// Apply status filter if needed
if (filters.status) {

View File

@ -2,7 +2,12 @@ import { Injectable, Inject } from "@nestjs/common";
import type { Invoice, InvoiceList } from "@customer-portal/domain/billing";
import type { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
import type { PaymentMethodList, PaymentGatewayList } from "@customer-portal/domain/payments";
import { Providers as CustomerProviders, type Address, type WhmcsClient } from "@customer-portal/domain/customer";
import {
Providers as CustomerProviders,
addressSchema,
type Address,
type WhmcsClient,
} from "@customer-portal/domain/customer";
import { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service";
import { WhmcsInvoiceService, InvoiceFilters } from "./services/whmcs-invoice.service";
import {
@ -150,7 +155,7 @@ export class WhmcsService {
*/
async getClientAddress(clientId: number): Promise<Address> {
const customer = await this.clientService.getClientDetails(clientId);
return (customer.address ?? {}) as Address;
return addressSchema.parse(customer.address ?? {});
}
async updateClientAddress(clientId: number, address: Partial<Address>): Promise<void> {

View File

@ -118,15 +118,13 @@ export class OrderBuilder {
| undefined;
const addressChanged = !!orderAddress;
const addressToUse = orderAddress || address;
const address1 = typeof addressToUse?.address1 === "string" ? addressToUse.address1 : "";
const address2 = typeof addressToUse?.address2 === "string" ? addressToUse.address2 : "";
const fullStreet = [address1, address2].filter(Boolean).join(", ");
orderFields.BillingStreet = fullStreet;
orderFields.BillingCity = typeof addressToUse?.city === "string" ? addressToUse.city : "";
orderFields.BillingState =
typeof addressToUse?.state === "string" ? addressToUse.state : "";
orderFields.BillingState = typeof addressToUse?.state === "string" ? addressToUse.state : "";
orderFields.BillingPostalCode =
typeof addressToUse?.postcode === "string" ? addressToUse.postcode : "";
orderFields.BillingCountry =

View File

@ -178,9 +178,9 @@ export default function ProfileContainer() {
<h2 className="text-xl font-semibold text-gray-900">Personal Information</h2>
</div>
{!editingProfile && (
<Button
variant="outline"
size="sm"
<Button
variant="outline"
size="sm"
onClick={() => setEditingProfile(true)}
leftIcon={<PencilIcon className="h-4 w-4" />}
>
@ -296,9 +296,9 @@ export default function ProfileContainer() {
<h2 className="text-xl font-semibold text-gray-900">Address Information</h2>
</div>
{!editingAddress && (
<Button
variant="outline"
size="sm"
<Button
variant="outline"
size="sm"
onClick={() => setEditingAddress(true)}
leftIcon={<PencilIcon className="h-4 w-4" />}
>
@ -378,7 +378,9 @@ export default function ProfileContainer() {
{address.values.address1 && (
<p className="font-medium text-base">{address.values.address1}</p>
)}
{address.values.address2 && <p className="text-gray-700">{address.values.address2}</p>}
{address.values.address2 && (
<p className="text-gray-700">{address.values.address2}</p>
)}
<p className="text-gray-700">
{[address.values.city, address.values.state, address.values.postcode]
.filter(Boolean)
@ -391,7 +393,7 @@ export default function ProfileContainer() {
<div className="text-center py-12">
<MapPinIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<p className="text-gray-600 mb-4">No address on file</p>
<Button
<Button
onClick={() => setEditingAddress(true)}
leftIcon={<PencilIcon className="h-4 w-4" />}
>

View File

@ -9,7 +9,11 @@ import { useState, useCallback, useMemo } from "react";
import Link from "next/link";
import { ErrorMessage } from "@/components/atoms";
import { useSignup } from "../../hooks/use-auth";
import { type SignupRequest, signupInputSchema } from "@customer-portal/domain/auth";
import {
type SignupRequest,
signupInputSchema,
buildSignupRequest,
} from "@customer-portal/domain/auth";
import { useZodForm } from "@customer-portal/validation";
import { z } from "zod";
@ -84,17 +88,10 @@ export function SignupForm({
})()
: undefined;
// Remove confirmPassword and create SignupRequest
// The signup hook will handle API format transformation
const request: SignupRequest = {
const request: SignupRequest = buildSignupRequest({
...formData,
...(normalizedAddress ? { address: normalizedAddress } : {}),
// API expects both camelCase and snake_case (for WHMCS compatibility)
firstname: formData.firstName,
lastname: formData.lastName,
companyname: formData.company,
phonenumber: formData.phone,
};
});
await signup(request);
onSuccess?.();
} catch (err) {

View File

@ -187,9 +187,12 @@ export function useSession() {
return undefined;
}
const interval = setInterval(() => {
void refreshSession();
}, 5 * 60 * 1000); // Check every 5 minutes
const interval = setInterval(
() => {
void refreshSession();
},
5 * 60 * 1000
); // Check every 5 minutes
return () => clearInterval(interval);
}, [isAuthenticated, refreshSession]);

View File

@ -18,10 +18,7 @@ import {
export function LoginView() {
const { loading, isAuthenticated } = useAuthStore();
const searchParams = useSearchParams();
const reasonParam = useMemo(
() => searchParams?.get("reason"),
[searchParams]
);
const reasonParam = useMemo(() => searchParams?.get("reason"), [searchParams]);
const [logoutReason, setLogoutReason] = useState<LogoutReason | null>(null);
const [dismissed, setDismissed] = useState(false);

View File

@ -118,7 +118,9 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex min-w-0 flex-1 items-start gap-4">
<div className={cn("flex h-12 w-12 items-center justify-center rounded-xl", iconStyles)}>
<div
className={cn("flex h-12 w-12 items-center justify-center rounded-xl", iconStyles)}
>
{serviceIcon}
</div>
<div className="min-w-0 space-y-2">
@ -128,7 +130,9 @@ export function OrderCard({ order, onClick, footer, className }: OrderCardProps)
Order #{order.orderNumber || String(order.id).slice(-8)}
</span>
<span className="hidden text-gray-300 sm:inline"></span>
<span>{formattedCreatedDate ? `Placed ${formattedCreatedDate}` : "Created date —"}</span>
<span>
{formattedCreatedDate ? `Placed ${formattedCreatedDate}` : "Created date —"}
</span>
</div>
<p className="text-sm text-gray-600 line-clamp-2">{statusDescriptor.description}</p>
</div>

View File

@ -1,3 +1,7 @@
import { normalizeBillingCycle } from "@customer-portal/domain/orders";
export { normalizeBillingCycle } from "@customer-portal/domain/orders";
export type OrderServiceCategory = "internet" | "sim" | "vpn" | "default";
export type StatusTone = "success" | "info" | "warning" | "neutral";
@ -128,12 +132,16 @@ export function calculateOrderTotals(
for (const item of items) {
const total = item.totalPrice ?? 0;
const billingCycle = normalizeBillingCycle(item.billingCycle);
if (billingCycle === "monthly") {
monthlyTotal += total;
} else if (billingCycle === "onetime") {
oneTimeTotal += total;
} else {
monthlyTotal += total;
switch (billingCycle) {
case "monthly":
monthlyTotal += total;
break;
case "onetime":
case "free":
oneTimeTotal += total;
break;
default:
monthlyTotal += total;
}
}
} else if (typeof fallbackTotal === "number") {
@ -143,14 +151,6 @@ export function calculateOrderTotals(
return { monthlyTotal, oneTimeTotal };
}
export function normalizeBillingCycle(value?: string): "monthly" | "onetime" | null {
if (!value) return null;
const normalized = value.trim().toLowerCase();
if (normalized === "monthly") return "monthly";
if (normalized === "onetime" || normalized === "one-time") return "onetime";
return null;
}
export function formatScheduledDate(scheduledAt?: string): string | undefined {
if (!scheduledAt) return undefined;
const date = new Date(scheduledAt);

View File

@ -121,12 +121,9 @@ interface PresentedItem {
}
const determineItemType = (item: OrderItemSummary): ItemPresentationType => {
const candidates = [
item.itemClass,
item.status,
item.productName,
item.name,
].map(value => value?.toLowerCase() ?? "");
const candidates = [item.itemClass, item.status, item.productName, item.name].map(
value => value?.toLowerCase() ?? ""
);
if (candidates.some(value => value.includes("install"))) {
return "installation";
@ -379,9 +376,7 @@ export function OrderDetailContainer() {
</div>
<div className="min-w-0 space-y-2">
<div className="flex flex-wrap items-baseline gap-2">
<p className="text-base font-semibold text-gray-900">
{item.name}
</p>
<p className="text-base font-semibold text-gray-900">{item.name}</p>
{item.sku && (
<span className="text-xs font-medium text-gray-400">
SKU {item.sku}

View File

@ -36,14 +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) {
@ -82,7 +75,7 @@ export function OrdersListContainer() {
{showRetry && (
<button
type="button"
onClick={() => refetch()}
onClick={() => void refetch()}
className="inline-flex items-center rounded-lg border border-blue-200 px-3 py-1.5 text-sm font-medium text-blue-700 transition hover:bg-blue-50 disabled:cursor-not-allowed disabled:opacity-60"
disabled={isFetching}
>

View File

@ -22,7 +22,6 @@ interface SubscriptionTableProps {
subscriptions: Subscription[];
loading?: boolean;
onSubscriptionClick?: (subscription: Subscription) => void;
compact?: boolean;
className?: string;
}
@ -89,7 +88,6 @@ export function SubscriptionTable({
subscriptions,
loading = false,
onSubscriptionClick,
compact = false,
className,
}: SubscriptionTableProps) {
const router = useRouter();
@ -163,7 +161,7 @@ export function SubscriptionTable({
),
},
];
}, [compact]);
}, []);
const emptyState = {
icon: <ServerIcon className="h-12 w-12" />,
@ -240,5 +238,3 @@ export function SubscriptionTable({
}
export type { SubscriptionTableProps };

View File

@ -1,3 +1,2 @@
export { SubscriptionTable } from './SubscriptionTable';
export type { SubscriptionTableProps } from './SubscriptionTable';
export { SubscriptionTable } from "./SubscriptionTable";
export type { SubscriptionTableProps } from "./SubscriptionTable";

View File

@ -185,7 +185,9 @@ export function SubscriptionDetailContainer() {
</h4>
<div className="flex items-center mt-2">
<CalendarIcon className="h-4 w-4 text-gray-400 mr-2" />
<p className="text-lg font-medium text-gray-900">{formatDate(subscription.nextDue)}</p>
<p className="text-lg font-medium text-gray-900">
{formatDate(subscription.nextDue)}
</p>
</div>
</div>
<div>

View File

@ -9,11 +9,7 @@ import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFi
import { LoadingTable } from "@/components/atoms/loading-skeleton";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { SubscriptionTable } from "@/features/subscriptions/components/SubscriptionTable";
import {
ServerIcon,
CheckCircleIcon,
XCircleIcon,
} from "@heroicons/react/24/outline";
import { ServerIcon, CheckCircleIcon, XCircleIcon } from "@heroicons/react/24/outline";
import { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks";
import type { Subscription } from "@customer-portal/domain/subscriptions";

View File

@ -0,0 +1,14 @@
import type { z } from "zod";
import { signupRequestSchema } from "./schema";
type SignupRequestInput = z.input<typeof signupRequestSchema>;
/**
* Convert signup form input into the canonical API payload.
* Reuses domain schema transformation to avoid frontend duplication.
*/
export function buildSignupRequest(input: SignupRequestInput) {
return signupRequestSchema.parse(input);
}

View File

@ -82,3 +82,5 @@ export {
ssoLinkResponseSchema,
checkPasswordNeededResponseSchema,
} from "./schema";
export { buildSignupRequest } from "./helpers";

View File

@ -13,6 +13,7 @@
import { z } from "zod";
import { countryCodeSchema } from "../common/schema";
import { whmcsClientSchema as whmcsRawClientSchema, whmcsCustomFieldSchema } from "./providers/whmcs/raw.types";
// ============================================================================
// Helper Schemas
@ -166,16 +167,7 @@ const statsSchema = z.record(
z.union([z.string(), z.number(), z.boolean()])
).optional();
const whmcsRawCustomFieldSchema = z
.object({
id: numberLike.optional(),
value: z.string().optional().nullable(),
name: z.string().optional(),
type: z.string().optional(),
})
.passthrough();
const whmcsRawCustomFieldsArraySchema = z.array(whmcsRawCustomFieldSchema);
const whmcsRawCustomFieldsArraySchema = z.array(whmcsCustomFieldSchema);
const whmcsCustomFieldsSchema = z
.union([
@ -184,7 +176,7 @@ const whmcsCustomFieldsSchema = z
z
.object({
customfield: z
.union([whmcsRawCustomFieldSchema, whmcsRawCustomFieldsArraySchema])
.union([whmcsCustomFieldSchema, whmcsRawCustomFieldsArraySchema])
.optional(),
})
.passthrough(),
@ -214,54 +206,69 @@ const whmcsUsersSchema = z
* - Preferences (email_preferences, allowSingleSignOn)
* - Relations (users, stats, customfields)
*/
export const whmcsClientSchema = z.object({
id: numberLike,
email: z.string(),
// Profile (raw WHMCS field names)
firstname: z.string().nullable().optional(),
lastname: z.string().nullable().optional(),
fullname: z.string().nullable().optional(),
companyname: z.string().nullable().optional(),
phonenumber: z.string().nullable().optional(),
phonenumberformatted: z.string().nullable().optional(),
telephoneNumber: z.string().nullable().optional(),
// Billing & Payment (raw WHMCS field names)
status: z.string().nullable().optional(),
language: z.string().nullable().optional(),
defaultgateway: z.string().nullable().optional(),
defaultpaymethodid: numberLike.nullable().optional(),
currency: numberLike.nullable().optional(),
currency_code: z.string().nullable().optional(), // snake_case from WHMCS
tax_id: z.string().nullable().optional(),
// Preferences (raw WHMCS field names)
allowSingleSignOn: booleanLike.nullable().optional(),
email_verified: booleanLike.nullable().optional(), // snake_case from WHMCS
marketing_emails_opt_in: booleanLike.nullable().optional(), // snake_case from WHMCS
// Metadata (raw WHMCS field names)
notes: z.string().nullable().optional(),
datecreated: z.string().nullable().optional(),
lastlogin: z.string().nullable().optional(),
// Relations
address: addressSchema.nullable().optional(),
email_preferences: emailPreferencesSchema.nullable().optional(), // snake_case from WHMCS
customfields: whmcsCustomFieldsSchema,
users: whmcsUsersSchema,
stats: statsSchema.optional(),
}).transform(data => ({
...data,
// Normalize types only, keep field names as-is
id: typeof data.id === 'number' ? data.id : Number(data.id),
allowSingleSignOn: normalizeBoolean(data.allowSingleSignOn),
email_verified: normalizeBoolean(data.email_verified),
marketing_emails_opt_in: normalizeBoolean(data.marketing_emails_opt_in),
defaultpaymethodid: data.defaultpaymethodid ? Number(data.defaultpaymethodid) : null,
currency: data.currency ? Number(data.currency) : null,
}));
const nullableProfileFields = [
"firstname",
"lastname",
"fullname",
"companyname",
"phonenumber",
"phonenumberformatted",
"telephoneNumber",
"status",
"language",
"defaultgateway",
"currency_code",
"tax_id",
"notes",
"datecreated",
"lastlogin",
] as const;
type NullableProfileKey = (typeof nullableProfileFields)[number];
const nullableProfileOverrides = nullableProfileFields.reduce<Record<string, z.ZodTypeAny>>(
(acc, field) => {
acc[field] = z.string().nullable().optional();
return acc;
},
{}
);
export const whmcsClientSchema = whmcsRawClientSchema
.extend({
...nullableProfileOverrides,
// Allow nullable numeric strings
defaultpaymethodid: numberLike.nullable().optional(),
currency: numberLike.nullable().optional(),
allowSingleSignOn: booleanLike.nullable().optional(),
email_verified: booleanLike.nullable().optional(),
marketing_emails_opt_in: booleanLike.nullable().optional(),
address: addressSchema.nullable().optional(),
email_preferences: emailPreferencesSchema.nullable().optional(),
customfields: whmcsCustomFieldsSchema,
users: whmcsUsersSchema,
stats: statsSchema.optional(),
})
.transform(raw => {
const coerceNumber = (value: unknown) =>
value === null || value === undefined ? null : Number(value);
const coerceOptionalNumber = (value: unknown) =>
value === null || value === undefined ? undefined : Number(value);
return {
...raw,
id: Number(raw.id),
client_id: coerceOptionalNumber((raw as Record<string, unknown>).client_id),
owner_user_id: coerceOptionalNumber((raw as Record<string, unknown>).owner_user_id),
userid: coerceOptionalNumber((raw as Record<string, unknown>).userid),
allowSingleSignOn: normalizeBoolean(raw.allowSingleSignOn),
email_verified: normalizeBoolean(raw.email_verified),
marketing_emails_opt_in: normalizeBoolean(raw.marketing_emails_opt_in),
defaultpaymethodid: coerceNumber(raw.defaultpaymethodid),
currency: coerceNumber(raw.currency),
};
});
// ============================================================================
// User Schema (API Response - Normalized camelCase)

View File

@ -5,6 +5,7 @@ import {
type OrderSelections,
} from "./schema";
import type { SimConfigureFormData } from "../sim";
import type { WhmcsOrderItem } from "./providers/whmcs/raw.types";
export interface BuildSimOrderConfigurationsOptions {
/**
@ -26,6 +27,65 @@ const normalizeDate = (value: unknown): string | undefined => {
return str.replace(/-/g, "");
};
type BillingCycle = WhmcsOrderItem["billingCycle"];
const BILLING_CYCLE_ALIASES: Record<string, BillingCycle> = {
monthly: "monthly",
month: "monthly",
onetime: "onetime",
once: "onetime",
singlepayment: "onetime",
annual: "annually",
annually: "annually",
yearly: "annually",
year: "annually",
quarterly: "quarterly",
quarter: "quarterly",
qtr: "quarterly",
semiannual: "semiannually",
semiannually: "semiannually",
semiannualy: "semiannually",
semiannualpayment: "semiannually",
semiannualbilling: "semiannually",
biannual: "semiannually",
biannually: "semiannually",
biennial: "biennially",
biennially: "biennially",
triennial: "triennially",
triennially: "triennially",
free: "free",
};
const normalizeBillingCycleKey = (value: string): string =>
value.trim().toLowerCase().replace(/[\s_-]+/g, "");
const DEFAULT_BILLING_CYCLE: BillingCycle = "monthly";
export interface NormalizeBillingCycleOptions {
defaultValue?: BillingCycle;
}
/**
* Normalize arbitrary billing cycle strings to the canonical WHMCS values.
* Keeps mapping logic in the domain so both BFF and UI stay in sync.
*/
export function normalizeBillingCycle(
value: unknown,
options: NormalizeBillingCycleOptions = {}
): BillingCycle {
if (typeof value !== "string") {
return options.defaultValue ?? DEFAULT_BILLING_CYCLE;
}
const directKey = normalizeBillingCycleKey(value);
const matched = BILLING_CYCLE_ALIASES[directKey];
if (matched) {
return matched;
}
return options.defaultValue ?? DEFAULT_BILLING_CYCLE;
}
/**
* Build an OrderConfigurations object for SIM orders from the shared SimConfigureFormData.
* Ensures the resulting payload conforms to the domain schema before it is sent to the BFF.

View File

@ -42,6 +42,7 @@ export * from "./validation";
export * from "./utils";
export {
buildSimOrderConfigurations,
normalizeBillingCycle,
normalizeOrderSelections,
type BuildSimOrderConfigurationsOptions,
} from "./helpers";

View File

@ -10,6 +10,7 @@ import type {
OrderItemSummary,
OrderSummary,
} from "../../contract";
import { normalizeBillingCycle } from "../../helpers";
import { orderDetailsSchema, orderSummarySchema, orderItemDetailsSchema } from "../../schema";
import type {
SalesforceOrderItemRecord,
@ -26,7 +27,10 @@ export function transformSalesforceOrderItem(
const pricebookEntry = record.PricebookEntry as Record<string, any> | null | undefined;
const product = pricebookEntry?.Product2 as Record<string, any> | undefined;
const productBillingCycle = product?.Billing_Cycle__c ?? undefined;
const billingCycle = record.Billing_Cycle__c ?? productBillingCycle ?? undefined;
const billingCycleRaw = record.Billing_Cycle__c ?? productBillingCycle ?? undefined;
const billingCycle = billingCycleRaw
? normalizeBillingCycle(billingCycleRaw)
: undefined;
const details = orderItemDetailsSchema.parse({
id: record.Id,

View File

@ -5,6 +5,7 @@
*/
import type { OrderDetails, OrderItemDetails } from "../../contract";
import { normalizeBillingCycle } from "../../helpers";
import {
type WhmcsOrderItem,
type WhmcsAddOrderParams,
@ -20,18 +21,6 @@ export interface OrderItemMappingResult {
};
}
function normalizeBillingCycle(cycle: string | undefined): WhmcsOrderItem["billingCycle"] {
if (!cycle) return "monthly"; // Default
const normalized = cycle.trim().toLowerCase();
if (normalized.includes("monthly")) return "monthly";
if (normalized.includes("one")) return "onetime";
if (normalized.includes("annual")) return "annually";
if (normalized.includes("quarter")) return "quarterly";
// Default to monthly if unrecognized
return "monthly";
}
/**
* Map a single order item to WHMCS format
*/