/** * 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"; function normalizeToArray(value: T | T[] | undefined | null): T[] { if (Array.isArray(value)) return value; return value ? [value] : []; } 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 = { 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 = { 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 | 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>((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 = normalizeToArray(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, }); }