- Added support for custom fields in WHMCS, including customer number, gender, and date of birth, to the environment validation schema. - Updated the signup workflow to handle new fields for date of birth and gender, ensuring they are included in the client creation process. - Implemented email update functionality in the user profile service, allowing users to change their email while ensuring uniqueness across the portal. - Enhanced the profile edit form to include fields for date of birth and gender, with appropriate validation. - Updated the UI to reflect changes in profile management, ensuring users can view and edit their information seamlessly. - Improved error handling and validation for user profile updates, ensuring a smoother user experience.
183 lines
5.5 KiB
TypeScript
183 lines
5.5 KiB
TypeScript
/**
|
|
* Shared WHMCS Provider Utilities
|
|
* Single source of truth for WHMCS data parsing
|
|
*
|
|
* Raw API types are source of truth - no fallbacks or variations expected.
|
|
*/
|
|
|
|
/**
|
|
* Parse amount from WHMCS API response
|
|
* WHMCS returns amounts as strings or numbers
|
|
*/
|
|
export function parseAmount(amount: string | number | undefined): number {
|
|
if (typeof amount === "number") return amount;
|
|
if (!amount) return 0;
|
|
|
|
const cleaned = String(amount).replace(/[^\d.-]/g, "");
|
|
const parsed = Number.parseFloat(cleaned);
|
|
return Number.isNaN(parsed) ? 0 : parsed;
|
|
}
|
|
|
|
/**
|
|
* Format date from WHMCS API to ISO string
|
|
* Returns undefined if input is invalid
|
|
*/
|
|
export function formatDate(input?: string | null): string | undefined {
|
|
if (!input) return undefined;
|
|
|
|
const date = new Date(input);
|
|
if (Number.isNaN(date.getTime())) return undefined;
|
|
|
|
return date.toISOString();
|
|
}
|
|
|
|
/**
|
|
* Normalize status using provided status map
|
|
* Generic helper for consistent status mapping
|
|
*/
|
|
export function normalizeStatus<T extends string>(
|
|
status: string | null | undefined,
|
|
statusMap: Record<string, T>,
|
|
defaultStatus: T
|
|
): T {
|
|
if (!status) return defaultStatus;
|
|
const mapped = statusMap[status.trim().toLowerCase()];
|
|
return mapped ?? defaultStatus;
|
|
}
|
|
|
|
/**
|
|
* Normalize billing cycle using provided cycle map
|
|
* Generic helper for consistent cycle mapping
|
|
*/
|
|
export function normalizeCycle<T extends string>(
|
|
cycle: string | null | undefined,
|
|
cycleMap: Record<string, T>,
|
|
defaultCycle: T
|
|
): T {
|
|
if (!cycle) return defaultCycle;
|
|
const normalized = cycle
|
|
.trim()
|
|
.toLowerCase()
|
|
.replace(/[_\s-]+/g, " ");
|
|
return cycleMap[normalized] ?? defaultCycle;
|
|
}
|
|
|
|
const isObject = (value: unknown): value is Record<string, unknown> =>
|
|
typeof value === "object" && value !== null;
|
|
|
|
const normalizeCustomFieldEntries = (value: unknown): Array<Record<string, unknown>> => {
|
|
if (Array.isArray(value)) return value.filter(isObject);
|
|
if (isObject(value) && "customfield" in value) {
|
|
const custom = (value as { customfield?: unknown }).customfield;
|
|
if (Array.isArray(custom)) return custom.filter(isObject);
|
|
if (isObject(custom)) return [custom];
|
|
return [];
|
|
}
|
|
return [];
|
|
};
|
|
|
|
/**
|
|
* Build a lightweight map of WHMCS custom field identifiers to values.
|
|
* Accepts the documented WHMCS response shapes (array or { customfield }).
|
|
*/
|
|
export function getCustomFieldsMap(customFields: unknown): Record<string, string> {
|
|
if (!customFields) return {};
|
|
|
|
if (isObject(customFields) && !Array.isArray(customFields) && !("customfield" in customFields)) {
|
|
return Object.entries(customFields).reduce<Record<string, string>>((acc, [key, value]) => {
|
|
if (typeof value === "string") {
|
|
const trimmedKey = key.trim();
|
|
if (trimmedKey) acc[trimmedKey] = value;
|
|
}
|
|
return acc;
|
|
}, {});
|
|
}
|
|
|
|
const map: Record<string, string> = {};
|
|
for (const entry of normalizeCustomFieldEntries(customFields)) {
|
|
const idRaw = "id" in entry ? entry.id : undefined;
|
|
const id =
|
|
typeof idRaw === "string"
|
|
? idRaw.trim()
|
|
: typeof idRaw === "number"
|
|
? String(idRaw)
|
|
: undefined;
|
|
const name = "name" in entry && typeof entry.name === "string" ? entry.name.trim() : undefined;
|
|
const rawValue = "value" in entry ? entry.value : undefined;
|
|
if (rawValue === undefined || rawValue === null) continue;
|
|
const value =
|
|
typeof rawValue === "string"
|
|
? rawValue
|
|
: typeof rawValue === "number" || typeof rawValue === "boolean"
|
|
? String(rawValue)
|
|
: undefined;
|
|
if (!value) continue;
|
|
|
|
if (id) map[id] = value;
|
|
if (name) map[name] = value;
|
|
}
|
|
|
|
return map;
|
|
}
|
|
|
|
/**
|
|
* Retrieve a custom field value by numeric id or name.
|
|
*/
|
|
export function getCustomFieldValue(
|
|
customFields: unknown,
|
|
key: string | number
|
|
): string | undefined {
|
|
if (key === undefined || key === null) return undefined;
|
|
const map = getCustomFieldsMap(customFields);
|
|
const primary = map[String(key)];
|
|
if (primary !== undefined) return primary;
|
|
|
|
if (typeof key === "string") {
|
|
const numeric = Number.parseInt(key, 10);
|
|
if (!Number.isNaN(numeric)) {
|
|
const numericValue = map[String(numeric)];
|
|
if (numericValue !== undefined) return numericValue;
|
|
}
|
|
}
|
|
|
|
return undefined;
|
|
}
|
|
|
|
/**
|
|
* Serialize a key/value map into the format WHMCS expects for request parameters like `customfields`.
|
|
*
|
|
* Official docs:
|
|
* - AddClient: customfields = "Base64 encoded serialized array of custom field values."
|
|
* @see https://developers.whmcs.com/api-reference/addclient/
|
|
*
|
|
* Notes:
|
|
* - WHMCS uses PHP serialization. For our usage, keys/values are strings.
|
|
* - Output is base64 of the serialized string.
|
|
*/
|
|
export function serializeWhmcsKeyValueMap(data?: Record<string, string>): string {
|
|
if (!data) return "";
|
|
const entries = Object.entries(data).filter(([k]) => String(k).trim().length > 0);
|
|
if (entries.length === 0) return "";
|
|
|
|
const serializedEntries = entries.map(([key, value]) => {
|
|
const safeKey = key ?? "";
|
|
const safeValue = value ?? "";
|
|
return (
|
|
`s:${byteLengthUtf8(safeKey)}:"${escapePhpString(safeKey)}";` +
|
|
`s:${byteLengthUtf8(safeValue)}:"${escapePhpString(safeValue)}";`
|
|
);
|
|
});
|
|
|
|
const serialized = `a:${serializedEntries.length}:{${serializedEntries.join("")}}`;
|
|
// Node runtime: base64 via Buffer
|
|
return Buffer.from(serialized, "utf8").toString("base64");
|
|
}
|
|
|
|
function escapePhpString(value: string): string {
|
|
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
}
|
|
|
|
function byteLengthUtf8(value: string): number {
|
|
return Buffer.byteLength(value, "utf8");
|
|
}
|