240 lines
6.8 KiB
TypeScript
Raw Normal View History

/**
* WHMCS Subscriptions Provider - Mapper
*
* Transforms raw WHMCS product/service data into normalized subscription types.
*/
import type {
Subscription,
SubscriptionStatus,
SubscriptionCycle,
SubscriptionList,
} from "../../contract.js";
import {
subscriptionSchema,
subscriptionListSchema,
subscriptionStatusSchema,
} from "../../schema.js";
import {
type WhmcsProductRaw,
whmcsProductRawSchema,
whmcsCustomFieldsContainerSchema,
whmcsProductListResponseSchema,
} from "./raw.types.js";
import {
parseAmount,
formatDate,
normalizeStatus,
normalizeCycle,
} from "../../../common/providers/whmcs-utils/index.js";
export interface TransformSubscriptionOptions {
defaultCurrencyCode?: string;
defaultCurrencySymbol?: string;
}
export interface TransformSubscriptionListResponseOptions extends TransformSubscriptionOptions {
status?: string;
onItemError?: (error: unknown, product: WhmcsProductRaw) => void;
}
// Status mapping
const STATUS_MAP: Record<string, SubscriptionStatus> = {
active: "Active",
inactive: "Inactive",
pending: "Pending",
cancelled: "Cancelled",
canceled: "Cancelled",
terminated: "Terminated",
completed: "Completed",
suspended: "Suspended",
fraud: "Cancelled",
};
// Cycle mapping
const CYCLE_SEMI_ANNUALLY: SubscriptionCycle = "Semi-Annually";
const CYCLE_MAP: Record<string, SubscriptionCycle> = {
monthly: "Monthly",
annually: "Annually",
annual: "Annually",
yearly: "Annually",
quarterly: "Quarterly",
"semi annually": CYCLE_SEMI_ANNUALLY,
semiannually: CYCLE_SEMI_ANNUALLY,
"semi-annually": CYCLE_SEMI_ANNUALLY,
biennially: "Biennially",
triennially: "Triennially",
"one time": "One-time",
onetime: "One-time",
"one-time": "One-time",
"one time fee": "One-time",
free: "Free",
};
function mapStatus(status?: string | null): SubscriptionStatus {
return normalizeStatus(status ?? undefined, STATUS_MAP, "Cancelled");
}
function mapCycle(cycle?: string | null): SubscriptionCycle {
return normalizeCycle(cycle ?? undefined, CYCLE_MAP, "One-time");
}
function extractCustomFields(raw: unknown): Record<string, string> | undefined {
if (!raw) return undefined;
const container = whmcsCustomFieldsContainerSchema.safeParse(raw);
if (!container.success) return undefined;
const customfield = container.data.customfield;
const fieldsArray = Array.isArray(customfield) ? customfield : [customfield];
const entries = fieldsArray.reduce<Record<string, string>>((acc, field) => {
if (field?.name && field.value) {
acc[field.name] = field.value;
}
return acc;
}, {});
return Object.keys(entries).length > 0 ? entries : undefined;
}
/**
* Extract currency info from product and options
*/
function extractCurrencyInfo(
product: WhmcsProductRaw,
options: TransformSubscriptionOptions
): { currency: string; currencySymbol: string | undefined } {
const currency = product.pricing?.currency || options.defaultCurrencyCode || "JPY";
const currencySymbol =
product.pricing?.currencyprefix ||
product.pricing?.currencysuffix ||
options.defaultCurrencySymbol;
return { currency, currencySymbol };
}
/**
* Extract amount from product pricing fields
*/
function extractAmount(product: WhmcsProductRaw): number {
const rawAmount =
product.amount ||
product.recurringamount ||
product.pricing?.amount ||
product.firstpaymentamount ||
0;
return parseAmount(rawAmount);
}
/**
* Extract optional string field with fallback
*/
function firstDefined(...values: (string | null | undefined)[]): string | undefined {
for (const v of values) {
if (v) return v;
}
return undefined;
}
/**
* Transform raw WHMCS product/service into normalized Subscription
*/
export function transformWhmcsSubscription(
rawProduct: unknown,
options: TransformSubscriptionOptions = {}
): Subscription {
const product = whmcsProductRawSchema.parse(rawProduct);
const { currency, currencySymbol } = extractCurrencyInfo(product, options);
const subscription: Subscription = {
id: product.id,
serviceId: product.serviceid || product.id,
productName: product.name || product.translated_name || "Unknown Product",
domain: product.domain || undefined,
cycle: mapCycle(product.billingcycle),
status: mapStatus(product.status),
nextDue: formatDate(product.nextduedate || product.nextinvoicedate),
amount: extractAmount(product),
currency,
currencySymbol,
registrationDate: formatDate(product.regdate) || new Date().toISOString(),
notes: product.notes || undefined,
customFields: extractCustomFields(product.customfields),
orderNumber: product.ordernumber || undefined,
groupName: firstDefined(product.groupname, product.translated_groupname),
paymentMethod: firstDefined(product.paymentmethodname, product.paymentmethod),
serverName: firstDefined(product.servername, product.serverhostname),
};
return subscriptionSchema.parse(subscription);
}
/**
* Transform multiple WHMCS subscriptions
*/
export function transformWhmcsSubscriptions(
rawProducts: unknown[],
options: TransformSubscriptionOptions = {}
): Subscription[] {
return rawProducts.map(raw => transformWhmcsSubscription(raw, options));
}
export function transformWhmcsSubscriptionListResponse(
response: unknown,
options: TransformSubscriptionListResponseOptions = {}
): SubscriptionList {
const parsed = whmcsProductListResponseSchema.parse(response);
const { status, onItemError, ...subscriptionOptions } = options;
if (parsed.result === "error") {
const message = parsed.message || "WHMCS GetClientsProducts returned error";
throw new Error(message);
}
const productContainer = parsed.products?.product;
const products = Array.isArray(productContainer)
? productContainer
: productContainer
? [productContainer]
: [];
const subscriptions: Subscription[] = [];
for (const product of products) {
try {
const subscription = transformWhmcsSubscription(product, subscriptionOptions);
subscriptions.push(subscription);
} catch (error) {
onItemError?.(error, product);
}
}
const totalResults = parsed.totalresults ?? subscriptions.length;
if (status) {
const normalizedStatus = subscriptionStatusSchema.parse(status);
const filtered = subscriptions.filter(sub => sub.status === normalizedStatus);
return subscriptionListSchema.parse({
subscriptions: filtered,
totalCount: filtered.length,
});
}
return subscriptionListSchema.parse({
subscriptions,
totalCount: totalResults,
});
}
export function filterSubscriptionsByStatus(
list: SubscriptionList,
status: string
): SubscriptionList {
const normalizedStatus = subscriptionStatusSchema.parse(status);
const filtered = list.subscriptions.filter(sub => sub.status === normalizedStatus);
return subscriptionListSchema.parse({
subscriptions: filtered,
totalCount: filtered.length,
});
}