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:
parent
0a3d5b1e3c
commit
67691a50b5
@ -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 {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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 =
|
||||
|
||||
@ -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" />}
|
||||
>
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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]);
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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);
|
||||
|
||||
@ -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}
|
||||
|
||||
@ -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}
|
||||
>
|
||||
|
||||
@ -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 };
|
||||
|
||||
|
||||
|
||||
@ -1,3 +1,2 @@
|
||||
export { SubscriptionTable } from './SubscriptionTable';
|
||||
export type { SubscriptionTableProps } from './SubscriptionTable';
|
||||
|
||||
export { SubscriptionTable } from "./SubscriptionTable";
|
||||
export type { SubscriptionTableProps } from "./SubscriptionTable";
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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";
|
||||
|
||||
|
||||
14
packages/domain/auth/helpers.ts
Normal file
14
packages/domain/auth/helpers.ts
Normal 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);
|
||||
}
|
||||
|
||||
@ -82,3 +82,5 @@ export {
|
||||
ssoLinkResponseSchema,
|
||||
checkPasswordNeededResponseSchema,
|
||||
} from "./schema";
|
||||
|
||||
export { buildSignupRequest } from "./helpers";
|
||||
|
||||
@ -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)
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -42,6 +42,7 @@ export * from "./validation";
|
||||
export * from "./utils";
|
||||
export {
|
||||
buildSimOrderConfigurations,
|
||||
normalizeBillingCycle,
|
||||
normalizeOrderSelections,
|
||||
type BuildSimOrderConfigurationsOptions,
|
||||
} from "./helpers";
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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
|
||||
*/
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user