diff --git a/apps/bff/src/integrations/freebit/interfaces/freebit.types.ts b/apps/bff/src/integrations/freebit/interfaces/freebit.types.ts index 62fc96bf..2ce6d8f5 100644 --- a/apps/bff/src/integrations/freebit/interfaces/freebit.types.ts +++ b/apps/bff/src/integrations/freebit/interfaces/freebit.types.ts @@ -310,49 +310,7 @@ export interface FreebitEsimAccountActivationResponse { } // Portal-specific types for SIM management -export interface SimDetails { - account: string; - status: "active" | "suspended" | "cancelled" | "pending"; - planCode: string; - planName: string; - simType: "standard" | "nano" | "micro" | "esim"; - iccid: string; - eid: string; - msisdn: string; - imsi: string; - remainingQuotaMb: number; - remainingQuotaKb: number; - voiceMailEnabled: boolean; - callWaitingEnabled: boolean; - internationalRoamingEnabled: boolean; - networkType: string; - activatedAt?: string; - expiresAt?: string; -} - -export interface SimUsage { - account: string; - todayUsageMb: number; - todayUsageKb: number; - monthlyUsageMb?: number; - monthlyUsageKb?: number; - recentDaysUsage: Array<{ date: string; usageKb: number; usageMb: number }>; - isBlacklisted: boolean; - lastUpdated?: string; -} - -export interface SimTopUpHistory { - account: string; - totalAdditions: number; - additionCount: number; - history: Array<{ - quotaKb: number; - quotaMb: number; - addedDate: string; - expiryDate: string; - campaignCode: string; - }>; -} +export type { SimDetails, SimUsage, SimTopUpHistory } from "@customer-portal/contracts/sim"; // Error handling export interface FreebitError extends Error { diff --git a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts index 1f905215..2c18604a 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-mapper.service.ts @@ -3,120 +3,43 @@ import type { FreebitAccountDetailsResponse, FreebitTrafficInfoResponse, FreebitQuotaHistoryResponse, - SimDetails, - SimUsage, - SimTopUpHistory, } from "../interfaces/freebit.types"; +import type { SimDetails, SimTopUpHistory, SimUsage } from "@customer-portal/contracts/sim"; +import { + transformFreebitAccountDetails, + transformFreebitQuotaHistory, + transformFreebitTrafficInfo, +} from "@customer-portal/integrations-freebit/mappers"; +import { normalizeAccount as normalizeAccountUtil } from "@customer-portal/integrations-freebit/utils"; @Injectable() export class FreebitMapperService { - /** - * Map SIM status from Freebit API to domain status - */ - mapSimStatus(status: string): "active" | "suspended" | "cancelled" | "pending" { - switch (status) { - case "active": - return "active"; - case "suspended": - return "suspended"; - case "temporary": - case "waiting": - return "pending"; - case "obsolete": - return "cancelled"; - default: - return "pending"; - } - } - /** * Map Freebit account details response to SimDetails */ mapToSimDetails(response: FreebitAccountDetailsResponse): SimDetails { - const account = response.responseDatas[0]; - if (!account) { - throw new Error("No account data in response"); - } - - let simType: "standard" | "nano" | "micro" | "esim" = "standard"; - if (account.eid) { - simType = "esim"; - } else if (account.simSize) { - simType = account.simSize; - } - - return { - account: String(account.account ?? ""), - status: this.mapSimStatus(String(account.state ?? account.status ?? "pending")), - planCode: String(account.planCode ?? ""), - planName: String(account.planName ?? ""), - simType, - iccid: String(account.iccid ?? ""), - eid: String(account.eid ?? ""), - msisdn: String(account.msisdn ?? account.account ?? ""), - imsi: String(account.imsi ?? ""), - remainingQuotaMb: Number(account.remainingQuotaMb ?? account.quota ?? 0), - remainingQuotaKb: Number(account.remainingQuotaKb ?? 0), - voiceMailEnabled: Boolean(account.voicemail ?? account.voiceMail ?? false), - callWaitingEnabled: Boolean(account.callwaiting ?? account.callWaiting ?? false), - internationalRoamingEnabled: Boolean(account.worldwing ?? account.worldWing ?? false), - networkType: String(account.networkType ?? account.contractLine ?? "4G"), - activatedAt: account.startDate ? String(account.startDate) : undefined, - expiresAt: account.async ? String(account.async.date) : undefined, - }; + return transformFreebitAccountDetails(response); } /** * Map Freebit traffic info response to SimUsage */ mapToSimUsage(response: FreebitTrafficInfoResponse): SimUsage { - if (!response.traffic) { - throw new Error("No traffic data in response"); - } - - const todayUsageKb = parseInt(response.traffic.today, 10) || 0; - const recentDaysData = response.traffic.inRecentDays.split(",").map((usage, index) => ({ - date: new Date(Date.now() - (index + 1) * 24 * 60 * 60 * 1000).toISOString().split("T")[0], - usageKb: parseInt(usage, 10) || 0, - usageMb: Math.round(((parseInt(usage, 10) || 0) / 1024) * 100) / 100, - })); - - return { - account: String(response.account ?? ""), - todayUsageMb: Math.round((todayUsageKb / 1024) * 100) / 100, - todayUsageKb, - recentDaysUsage: recentDaysData, - isBlacklisted: response.traffic.blackList === "10", - }; + return transformFreebitTrafficInfo(response); } /** * Map Freebit quota history response to SimTopUpHistory */ mapToSimTopUpHistory(response: FreebitQuotaHistoryResponse, account: string): SimTopUpHistory { - if (!response.quotaHistory) { - throw new Error("No history data in response"); - } - - return { - account, - totalAdditions: Number(response.total) || 0, - additionCount: Number(response.count) || 0, - history: response.quotaHistory.map(item => ({ - quotaKb: parseInt(item.quota, 10), - quotaMb: Math.round((parseInt(item.quota, 10) / 1024) * 100) / 100, - addedDate: item.date, - expiryDate: item.expire, - campaignCode: item.quotaCode, - })), - }; + return transformFreebitQuotaHistory(response, account); } /** * Normalize account identifier (remove formatting) */ normalizeAccount(account: string): string { - return account.replace(/[-\s()]/g, ""); + return normalizeAccountUtil(account); } /** diff --git a/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts b/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts index 37a6393b..91b46fce 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-operations.service.ts @@ -5,30 +5,27 @@ import { FreebitClientService } from "./freebit-client.service"; import { FreebitMapperService } from "./freebit-mapper.service"; import { FreebitAuthService } from "./freebit-auth.service"; import type { - FreebitAccountDetailsRequest, FreebitAccountDetailsResponse, - FreebitTrafficInfoRequest, FreebitTrafficInfoResponse, - FreebitTopUpRequest, FreebitTopUpResponse, - FreebitQuotaHistoryRequest, FreebitQuotaHistoryResponse, - FreebitPlanChangeRequest, FreebitPlanChangeResponse, - FreebitAddSpecRequest, FreebitAddSpecResponse, - FreebitCancelPlanRequest, FreebitCancelPlanResponse, - FreebitEsimReissueRequest, FreebitEsimReissueResponse, - FreebitEsimAddAccountRequest, FreebitEsimAddAccountResponse, - FreebitEsimAccountActivationRequest, FreebitEsimAccountActivationResponse, - SimDetails, - SimUsage, - SimTopUpHistory, } from "../interfaces/freebit.types"; +import type { SimDetails, SimTopUpHistory, SimUsage } from "@customer-portal/contracts/sim"; +import { + freebitAccountDetailsRequestSchema, + freebitAddSpecRequestSchema, + freebitCancelPlanRequestSchema, + freebitEsimReissueRequestSchema, + freebitPlanChangeRequestSchema, + freebitTopUpRequestPayloadSchema, + freebitTrafficInfoRequestSchema, +} from "@customer-portal/schemas/integrations/freebit/requests"; @Injectable() export class FreebitOperationsService { @@ -44,10 +41,10 @@ export class FreebitOperationsService { */ async getSimDetails(account: string): Promise { try { - const request: Omit = { + const request = freebitAccountDetailsRequestSchema.parse({ version: "2", requestDatas: [{ kind: "MVNO", account }], - }; + }); const config = this.auth.getConfig(); const configured = config.detailsEndpoint || "/master/getAcnt/"; @@ -116,7 +113,7 @@ export class FreebitOperationsService { */ async getSimUsage(account: string): Promise { try { - const request: Omit = { account }; + const request = freebitTrafficInfoRequestSchema.parse({ account }); const response = await this.client.makeAuthenticatedRequest< FreebitTrafficInfoResponse, @@ -143,17 +140,20 @@ export class FreebitOperationsService { options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {} ): Promise { try { - const quotaKb = Math.round(quotaMb * 1024); - const baseRequest: Omit = { - account, + const payload = freebitTopUpRequestPayloadSchema.parse({ account, quotaMb, options }); + const quotaKb = Math.round(payload.quotaMb * 1024); + const baseRequest = { + account: payload.account, quota: quotaKb, - quotaCode: options.campaignCode, - expire: options.expiryDate, + quotaCode: payload.options?.campaignCode, + expire: payload.options?.expiryDate, }; - const scheduled = !!options.scheduledAt; + const scheduled = Boolean(payload.options?.scheduledAt); const endpoint = scheduled ? "/mvno/eachQuota/" : "/master/addSpec/"; - const request = scheduled ? { ...baseRequest, runTime: options.scheduledAt } : baseRequest; + const request = scheduled + ? { ...baseRequest, runTime: payload.options?.scheduledAt } + : baseRequest; await this.client.makeAuthenticatedRequest( endpoint, @@ -220,11 +220,18 @@ export class FreebitOperationsService { options: { assignGlobalIp?: boolean; scheduledAt?: string } = {} ): Promise<{ ipv4?: string; ipv6?: string }> { try { - const request: Omit = { + const parsed = freebitPlanChangeRequestSchema.parse({ account, - plancode: newPlanCode, - globalip: options.assignGlobalIp ? "1" : "0", - runTime: options.scheduledAt, + newPlanCode, + assignGlobalIp: options.assignGlobalIp, + scheduledAt: options.scheduledAt, + }); + + const request = { + account: parsed.account, + plancode: parsed.newPlanCode, + globalip: parsed.assignGlobalIp ? "1" : "0", + runTime: parsed.scheduledAt, }; const response = await this.client.makeAuthenticatedRequest< @@ -232,11 +239,11 @@ export class FreebitOperationsService { typeof request >("/mvno/changePlan/", request); - this.logger.log(`Successfully changed plan for account ${account} to ${newPlanCode}`, { - account, - newPlanCode, - assignGlobalIp: options.assignGlobalIp, - scheduled: !!options.scheduledAt, + this.logger.log(`Successfully changed plan for account ${parsed.account} to ${parsed.newPlanCode}`, { + account: parsed.account, + newPlanCode: parsed.newPlanCode, + assignGlobalIp: parsed.assignGlobalIp, + scheduled: Boolean(parsed.scheduledAt), }); return { @@ -267,28 +274,41 @@ export class FreebitOperationsService { } ): Promise { try { - const request: Omit = { account }; + const request = freebitAddSpecRequestSchema.parse({ + account, + specCode: "FEATURES", + networkType: features.networkType, + }); + + const payload: Record = { + account: request.account, + }; - // Use both variations for compatibility if (typeof features.voiceMailEnabled === "boolean") { - request.voiceMail = features.voiceMailEnabled ? "10" : "20"; - request.voicemail = request.voiceMail; - } - if (typeof features.callWaitingEnabled === "boolean") { - request.callWaiting = features.callWaitingEnabled ? "10" : "20"; - request.callwaiting = request.callWaiting; - } - if (typeof features.internationalRoamingEnabled === "boolean") { - request.worldWing = features.internationalRoamingEnabled ? "10" : "20"; - request.worldwing = request.worldWing; - } - if (features.networkType) { - request.contractLine = features.networkType; + const flag = features.voiceMailEnabled ? "10" : "20"; + payload.voiceMail = flag; + payload.voicemail = flag; } - await this.client.makeAuthenticatedRequest( + if (typeof features.callWaitingEnabled === "boolean") { + const flag = features.callWaitingEnabled ? "10" : "20"; + payload.callWaiting = flag; + payload.callwaiting = flag; + } + + if (typeof features.internationalRoamingEnabled === "boolean") { + const flag = features.internationalRoamingEnabled ? "10" : "20"; + payload.worldWing = flag; + payload.worldwing = flag; + } + + if (request.networkType) { + payload.contractLine = request.networkType; + } + + await this.client.makeAuthenticatedRequest( "/master/addSpec/", - request + payload ); this.logger.log(`Successfully updated SIM features for account ${account}`, { @@ -314,9 +334,14 @@ export class FreebitOperationsService { */ async cancelSim(account: string, scheduledAt?: string): Promise { try { - const request: Omit = { + const parsed = freebitCancelPlanRequestSchema.parse({ account, - runTime: scheduledAt, + runDate: scheduledAt, + }); + + const request = { + account: parsed.account, + runTime: parsed.runDate, }; await this.client.makeAuthenticatedRequest( @@ -344,7 +369,7 @@ export class FreebitOperationsService { */ async reissueEsimProfile(account: string): Promise { try { - const request: Omit = { + const request = { requestDatas: [{ kind: "MVNO", account }], }; @@ -373,12 +398,20 @@ export class FreebitOperationsService { options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {} ): Promise { try { + const parsed = freebitEsimReissueRequestSchema.parse({ + account, + newEid, + oldEid: options.oldEid, + planCode: options.planCode, + oldProductNumber: options.oldProductNumber, + }); + const request: Omit = { aladinOperated: "20", - account, - eid: newEid, + account: parsed.account, + eid: parsed.newEid, addKind: "R", - planCode: options.planCode, + planCode: parsed.planCode, }; await this.client.makeAuthenticatedRequest( @@ -386,11 +419,11 @@ export class FreebitOperationsService { request ); - this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${account}`, { - account, - newEid, - oldProductNumber: options.oldProductNumber, - oldEid: options.oldEid, + this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${parsed.account}`, { + account: parsed.account, + newEid: parsed.newEid, + oldProductNumber: parsed.oldProductNumber, + oldEid: parsed.oldEid, }); } catch (error) { const message = getErrorMessage(error); diff --git a/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts index 8a3a0d39..57d26445 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts @@ -1,16 +1,11 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { Invoice, InvoiceItem as BaseInvoiceItem } from "@customer-portal/domain"; -import type { WhmcsInvoice, WhmcsInvoiceItems } from "../../types/whmcs-api.types"; +import type { Invoice } from "@customer-portal/contracts/billing"; +import { transformWhmcsInvoice } from "@customer-portal/integrations-whmcs/mappers"; +import type { WhmcsInvoice } from "../../types/whmcs-api.types"; import { DataUtils } from "../utils/data-utils"; -import { TransformationValidator } from "../validators/transformation-validator"; import { WhmcsCurrencyService } from "../../services/whmcs-currency.service"; -// Extended InvoiceItem interface to include serviceId -interface InvoiceItem extends BaseInvoiceItem { - serviceId?: number; -} - /** * Service responsible for transforming WHMCS invoice data */ @@ -18,7 +13,6 @@ interface InvoiceItem extends BaseInvoiceItem { export class InvoiceTransformerService { constructor( @Inject(Logger) private readonly logger: Logger, - private readonly validator: TransformationValidator, private readonly currencyService: WhmcsCurrencyService ) {} @@ -28,47 +22,12 @@ export class InvoiceTransformerService { transformInvoice(whmcsInvoice: WhmcsInvoice): Invoice { const invoiceId = whmcsInvoice.invoiceid || whmcsInvoice.id; - if (!this.validator.validateWhmcsInvoiceData(whmcsInvoice)) { - throw new Error("Invalid invoice data from WHMCS"); - } - try { - // Use WHMCS system default currency if not provided in invoice const defaultCurrency = this.currencyService.getDefaultCurrency(); - const currency = whmcsInvoice.currencycode || defaultCurrency.code; - const currencySymbol = - whmcsInvoice.currencyprefix || - whmcsInvoice.currencysuffix || - defaultCurrency.prefix || - defaultCurrency.suffix; - - // Parse dates first to use in status determination - const dueDate = DataUtils.formatDate(whmcsInvoice.duedate); - const paidDate = DataUtils.formatDate(whmcsInvoice.datepaid); - const issuedAt = DataUtils.formatDate(whmcsInvoice.date || whmcsInvoice.datecreated); - - const finalStatus = this.mapInvoiceStatus(whmcsInvoice.status); - - const invoice: Invoice = { - id: Number(invoiceId), - number: whmcsInvoice.invoicenum || `INV-${invoiceId}`, - status: finalStatus, - currency, - currencySymbol, - total: DataUtils.parseAmount(whmcsInvoice.total), - subtotal: DataUtils.parseAmount(whmcsInvoice.subtotal), - tax: DataUtils.parseAmount(whmcsInvoice.tax) + DataUtils.parseAmount(whmcsInvoice.tax2), - issuedAt, - dueDate, - paidDate, - description: whmcsInvoice.notes || undefined, - items: this.transformInvoiceItems(whmcsInvoice.items), - daysOverdue: undefined, - }; - - if (!this.validator.validateInvoice(invoice)) { - throw new Error("Transformed invoice failed validation"); - } + const invoice = transformWhmcsInvoice(whmcsInvoice, { + defaultCurrencyCode: defaultCurrency.code, + defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix, + }); this.logger.debug(`Transformed invoice ${invoice.id}`, { originalStatus: whmcsInvoice.status, @@ -78,7 +37,7 @@ export class InvoiceTransformerService { currency: invoice.currency, itemCount: invoice.items?.length || 0, itemsWithServices: - invoice.items?.filter((item: InvoiceItem) => Boolean(item.serviceId)).length || 0, + invoice.items?.filter(item => Boolean(item.serviceId)).length || 0, }); return invoice; @@ -93,42 +52,6 @@ export class InvoiceTransformerService { } } - /** - * Transform WHMCS invoice items to our standard format - */ - private transformInvoiceItems(items: WhmcsInvoiceItems | undefined): InvoiceItem[] { - if (!items || !items.item) return []; - - // WHMCS API returns either an array or single item - const itemsArray = Array.isArray(items.item) ? items.item : [items.item]; - - return itemsArray.map(item => this.transformSingleInvoiceItem(item)); - } - - /** - * Transform a single invoice item using exact WHMCS API structure - */ - private transformSingleInvoiceItem(item: WhmcsInvoiceItems["item"][0]): InvoiceItem { - const transformedItem: InvoiceItem = { - id: item.id, - description: item.description, - amount: DataUtils.parseAmount(item.amount), - quantity: 1, // WHMCS invoice items don't have quantity field, always 1 - type: item.type, - }; - - // Add service ID from relid field - // In WHMCS: relid > 0 means linked to service, relid = 0 means one-time item - if (typeof item.relid === "number" && item.relid > 0) { - transformedItem.serviceId = item.relid; - } - - // Note: taxed field exists in WHMCS but not in our domain schema - // Tax information is handled at the invoice level - - return transformedItem; - } - /** * Transform multiple invoices in batch */ @@ -163,24 +86,4 @@ export class InvoiceTransformerService { return results; } - private mapInvoiceStatus(status: string): Invoice["status"] { - const allowed: Invoice["status"][] = [ - "Draft", - "Unpaid", - "Paid", - "Pending", - "Cancelled", - "Refunded", - "Collections", - "Overdue", - ]; - - const normalizedStatus = status === "Payment Pending" ? "Pending" : status; - - if (allowed.includes(normalizedStatus as Invoice["status"])) { - return normalizedStatus as Invoice["status"]; - } - - throw new Error(`Unsupported WHMCS invoice status: ${status}`); - } } diff --git a/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts index 03f9e551..48f52eed 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts @@ -1,37 +1,26 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { PaymentMethod, PaymentGateway } from "@customer-portal/domain"; -import type { WhmcsPaymentMethod, WhmcsPaymentGateway } from "../../types/whmcs-api.types"; +import type { PaymentGateway, PaymentMethod } from "@customer-portal/contracts/payments"; +import { + transformWhmcsPaymentGateway, + transformWhmcsPaymentMethod, +} from "@customer-portal/integrations-whmcs/mappers"; +import type { WhmcsPaymentGateway, WhmcsPaymentMethod } from "../../types/whmcs-api.types"; import { DataUtils } from "../utils/data-utils"; -import { TransformationValidator } from "../validators/transformation-validator"; /** * Service responsible for transforming WHMCS payment-related data */ @Injectable() export class PaymentTransformerService { - constructor( - @Inject(Logger) private readonly logger: Logger, - private readonly validator: TransformationValidator - ) {} + constructor(@Inject(Logger) private readonly logger: Logger) {} /** * Transform WHMCS payment gateway to shared PaymentGateway interface */ transformPaymentGateway(whmcsGateway: WhmcsPaymentGateway): PaymentGateway { try { - const gateway: PaymentGateway = { - name: whmcsGateway.name, - displayName: whmcsGateway.display_name, - type: whmcsGateway.type, - isActive: whmcsGateway.active, - }; - - if (!this.validator.validatePaymentGateway(gateway)) { - throw new Error("Transformed payment gateway failed validation"); - } - - return gateway; + return transformWhmcsPaymentGateway(whmcsGateway); } catch (error) { this.logger.error("Failed to transform payment gateway", { error: DataUtils.toErrorMessage(error), @@ -45,29 +34,15 @@ export class PaymentTransformerService { * Transform WHMCS payment method to shared PaymentMethod interface */ transformPaymentMethod(whmcsPayMethod: WhmcsPaymentMethod): PaymentMethod { - const transformed: PaymentMethod = { - id: whmcsPayMethod.id, - type: whmcsPayMethod.type, - description: whmcsPayMethod.description, - gatewayName: whmcsPayMethod.gateway_name || undefined, - contactType: whmcsPayMethod.contact_type || undefined, - contactId: whmcsPayMethod.contact_id ?? undefined, - cardLastFour: whmcsPayMethod.card_last_four || undefined, - expiryDate: whmcsPayMethod.expiry_date || undefined, - startDate: whmcsPayMethod.start_date || undefined, - issueNumber: whmcsPayMethod.issue_number || undefined, - cardType: whmcsPayMethod.card_type || undefined, - remoteToken: whmcsPayMethod.remote_token || undefined, - lastUpdated: whmcsPayMethod.last_updated || undefined, - bankName: whmcsPayMethod.bank_name || undefined, - isDefault: false, - }; - - if (!this.validator.validatePaymentMethod(transformed)) { - throw new Error("Transformed payment method failed validation"); + try { + return transformWhmcsPaymentMethod(whmcsPayMethod); + } catch (error) { + this.logger.error("Failed to transform payment method", { + error: DataUtils.toErrorMessage(error), + payMethodId: whmcsPayMethod?.id, + }); + throw error; } - - return transformed; } /** diff --git a/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts index 6a92f322..f44218b7 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts @@ -1,9 +1,9 @@ import { Injectable, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { Subscription } from "@customer-portal/domain"; -import type { WhmcsProduct, WhmcsCustomField } from "../../types/whmcs-api.types"; +import type { Subscription } from "@customer-portal/contracts/subscriptions"; +import { transformWhmcsSubscription } from "@customer-portal/integrations-whmcs/mappers"; +import type { WhmcsProduct } from "../../types/whmcs-api.types"; import { DataUtils } from "../utils/data-utils"; -import { TransformationValidator } from "../validators/transformation-validator"; import { WhmcsCurrencyService } from "../../services/whmcs-currency.service"; /** @@ -13,7 +13,6 @@ import { WhmcsCurrencyService } from "../../services/whmcs-currency.service"; export class SubscriptionTransformerService { constructor( @Inject(Logger) private readonly logger: Logger, - private readonly validator: TransformationValidator, private readonly currencyService: WhmcsCurrencyService ) {} @@ -21,38 +20,13 @@ export class SubscriptionTransformerService { * Transform WHMCS product/service to our standard Subscription format */ transformSubscription(whmcsProduct: WhmcsProduct): Subscription { - if (!this.validator.validateWhmcsProductData(whmcsProduct)) { - throw new Error("Invalid product data from WHMCS"); - } - try { - const billingCycle = this.mapBillingCycle(whmcsProduct.billingcycle); - - // Use WHMCS system default currency const defaultCurrency = this.currencyService.getDefaultCurrency(); - const subscription: Subscription = { - id: Number(whmcsProduct.id), - serviceId: Number(whmcsProduct.id), // In WHMCS, product ID is the service ID - productName: whmcsProduct.name || "", - domain: whmcsProduct.domain || undefined, - status: this.mapSubscriptionStatus(whmcsProduct.status), - cycle: billingCycle, - amount: this.getProductAmount(whmcsProduct), - currency: defaultCurrency.code, - currencySymbol: defaultCurrency.prefix || defaultCurrency.suffix, - nextDue: DataUtils.formatDate(whmcsProduct.nextduedate), - registrationDate: DataUtils.formatDate(whmcsProduct.regdate) || new Date().toISOString(), - customFields: this.extractCustomFields(whmcsProduct.customfields?.customfield), - notes: undefined, // WhmcsProduct doesn't have notes field - }; - - // Note: setupFee and discount are not part of the domain Subscription schema - // They would need to be added to the schema if required - - if (!this.validator.validateSubscription(subscription)) { - throw new Error("Transformed subscription failed validation"); - } + const subscription = transformWhmcsSubscription(whmcsProduct, { + defaultCurrencyCode: defaultCurrency.code, + defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix, + }); this.logger.debug(`Transformed subscription ${subscription.id}`, { productName: subscription.productName, @@ -78,94 +52,6 @@ export class SubscriptionTransformerService { } } - /** - * Get the appropriate amount for a product (recurring vs first payment) - */ - private getProductAmount(whmcsProduct: WhmcsProduct): number { - const recurring = DataUtils.parseAmount(whmcsProduct.recurringamount); - return recurring > 0 ? recurring : DataUtils.parseAmount(whmcsProduct.firstpaymentamount); - } - - /** - * Extract custom fields from WHMCS format without renaming - */ - private extractCustomFields( - customFields: WhmcsCustomField[] | undefined - ): Record | undefined { - if (!customFields || !Array.isArray(customFields) || customFields.length === 0) { - return undefined; - } - - try { - const fields: Record = {}; - for (const field of customFields) { - if (field && typeof field === "object" && field.name && field.value) { - fields[field.name] = field.value; - } - } - - return Object.keys(fields).length > 0 ? fields : undefined; - } catch (error) { - this.logger.warn("Failed to extract custom fields", { - error: DataUtils.toErrorMessage(error), - customFieldsCount: customFields?.length || 0, - }); - return undefined; - } - } - - /** - * Normalize field name to camelCase - */ - private mapSubscriptionStatus(status: string | undefined): Subscription["status"] { - if (typeof status !== "string") { - return "Cancelled"; - } - - const normalized = status.trim().toLowerCase(); - - const statusMap: Record = { - active: "Active", - completed: "Completed", - cancelled: "Cancelled", - canceled: "Cancelled", - terminated: "Cancelled", - suspended: "Cancelled", - pending: "Active", - fraud: "Cancelled", - }; - - return statusMap[normalized] ?? "Cancelled"; - } - - private mapBillingCycle(cycle: string | undefined): Subscription["cycle"] { - if (typeof cycle !== "string" || cycle.trim().length === 0) { - return "One-time"; - } - - const normalized = cycle.trim().toLowerCase().replace(/[_\s-]+/g, " "); - - const cycleMap: Record = { - monthly: "Monthly", - annually: "Annually", - annual: "Annually", - yearly: "Annually", - quarterly: "Quarterly", - "semi annually": "Semi-Annually", - "semiannually": "Semi-Annually", - "semi-annually": "Semi-Annually", - biennially: "Biennially", - triennially: "Triennially", - "one time": "One-time", - onetime: "One-time", - "one-time": "One-time", - "one time fee": "One-time", - free: "Free", - }; - - return cycleMap[normalized] ?? "One-time"; - } - /** * Transform multiple subscriptions in batch */ diff --git a/apps/bff/src/integrations/whmcs/transformers/services/whmcs-transformer-orchestrator.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/whmcs-transformer-orchestrator.service.ts index 3acc1659..73bd9271 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/whmcs-transformer-orchestrator.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/whmcs-transformer-orchestrator.service.ts @@ -10,7 +10,6 @@ import type { import { InvoiceTransformerService } from "./invoice-transformer.service"; import { SubscriptionTransformerService } from "./subscription-transformer.service"; import { PaymentTransformerService } from "./payment-transformer.service"; -import { TransformationValidator } from "../validators/transformation-validator"; import { DataUtils } from "../utils/data-utils"; /** @@ -23,8 +22,7 @@ export class WhmcsTransformerOrchestratorService { @Inject(Logger) private readonly logger: Logger, private readonly invoiceTransformer: InvoiceTransformerService, private readonly subscriptionTransformer: SubscriptionTransformerService, - private readonly paymentTransformer: PaymentTransformerService, - private readonly validator: TransformationValidator + private readonly paymentTransformer: PaymentTransformerService ) {} /** diff --git a/apps/bff/src/integrations/whmcs/transformers/validators/transformation-validator.ts b/apps/bff/src/integrations/whmcs/transformers/validators/transformation-validator.ts deleted file mode 100644 index 955dafd6..00000000 --- a/apps/bff/src/integrations/whmcs/transformers/validators/transformation-validator.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { Injectable } from "@nestjs/common"; -import { Invoice, Subscription, PaymentMethod, PaymentGateway } from "@customer-portal/domain"; -import type { WhmcsInvoice, WhmcsProduct } from "../../types/whmcs-api.types"; - -/** - * Service for validating transformed data objects - */ -@Injectable() -export class TransformationValidator { - /** - * Validate invoice transformation result - */ - validateInvoice(invoice: Invoice): boolean { - const requiredFields = [ - "id", - "number", - "status", - "currency", - "total", - "subtotal", - "tax", - "issuedAt", - ]; - - return requiredFields.every(field => { - const value = invoice[field as keyof Invoice]; - return value !== undefined && value !== null; - }); - } - - /** - * Validate subscription transformation result - */ - validateSubscription(subscription: Subscription): boolean { - const requiredFields = ["id", "serviceId", "productName", "status", "currency"]; - - return requiredFields.every(field => { - const value = subscription[field as keyof Subscription]; - return value !== undefined && value !== null; - }); - } - - /** - * Validate payment method transformation result - */ - validatePaymentMethod(paymentMethod: PaymentMethod): boolean { - const requiredFields = ["id", "type", "description"]; - - return requiredFields.every(field => { - const value = paymentMethod[field as keyof PaymentMethod]; - return value !== undefined && value !== null; - }); - } - - /** - * Validate payment gateway transformation result - */ - validatePaymentGateway(gateway: PaymentGateway): boolean { - const requiredFields = ["name", "displayName", "type", "isActive"]; - - return requiredFields.every(field => { - const value = gateway[field as keyof PaymentGateway]; - return value !== undefined && value !== null; - }); - } - - /** - * Validate invoice items array - */ - validateInvoiceItems( - items: Array<{ description: string; amount: string; id: number; type: string; relid: number }> - ): boolean { - if (!Array.isArray(items)) return false; - - return items.every(item => { - return Boolean(item.description && item.amount && item.id); - }); - } - - /** - * Validate that required WHMCS invoice data is present - */ - validateWhmcsInvoiceData(whmcsInvoice: WhmcsInvoice): boolean { - return Boolean(whmcsInvoice.invoiceid || whmcsInvoice.id); - } - - /** - * Validate that required WHMCS product data is present - */ - validateWhmcsProductData(whmcsProduct: WhmcsProduct): boolean { - return Boolean(whmcsProduct.id); - } - - /** - * Validate currency code format - */ - validateCurrencyCode(currency: string): boolean { - if (!currency || typeof currency !== "string") return false; - - // Check if it's a valid 3-letter currency code - return /^[A-Z]{3}$/.test(currency.toUpperCase()); - } - - /** - * Validate amount is a valid number - */ - validateAmount(amount: string | number): boolean { - if (typeof amount === "number") { - return !isNaN(amount) && isFinite(amount); - } - - if (typeof amount === "string") { - const parsed = parseFloat(amount); - return !isNaN(parsed) && isFinite(parsed); - } - - return false; - } - - /** - * Validate date string format - */ - validateDateString(dateStr: string): boolean { - if (!dateStr) return false; - - const date = new Date(dateStr); - return !isNaN(date.getTime()); - } -} diff --git a/apps/bff/src/integrations/whmcs/whmcs.module.ts b/apps/bff/src/integrations/whmcs/whmcs.module.ts index 998b4559..491c94c6 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.module.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.module.ts @@ -15,7 +15,6 @@ import { WhmcsTransformerOrchestratorService } from "./transformers/services/whm import { InvoiceTransformerService } from "./transformers/services/invoice-transformer.service"; import { SubscriptionTransformerService } from "./transformers/services/subscription-transformer.service"; import { PaymentTransformerService } from "./transformers/services/payment-transformer.service"; -import { TransformationValidator } from "./transformers/validators/transformation-validator"; // New connection services import { WhmcsConnectionOrchestratorService } from "./connection/services/whmcs-connection-orchestrator.service"; import { WhmcsConfigService } from "./connection/config/whmcs-config.service"; @@ -31,7 +30,6 @@ import { WhmcsApiMethodsService } from "./connection/services/whmcs-api-methods. InvoiceTransformerService, SubscriptionTransformerService, PaymentTransformerService, - TransformationValidator, // New modular connection services WhmcsConnectionOrchestratorService, WhmcsConfigService, diff --git a/apps/bff/src/modules/orders/services/order-whmcs-mapper.service.ts b/apps/bff/src/modules/orders/services/order-whmcs-mapper.service.ts index 7583be77..2dd02a57 100644 --- a/apps/bff/src/modules/orders/services/order-whmcs-mapper.service.ts +++ b/apps/bff/src/modules/orders/services/order-whmcs-mapper.service.ts @@ -1,8 +1,9 @@ import { Injectable, BadRequestException, Inject } from "@nestjs/common"; import { Logger } from "nestjs-pino"; -import { WhmcsOrderItem } from "@bff/integrations/whmcs/services/whmcs-order.service"; -import type { FulfillmentOrderItem } from "../types/fulfillment.types"; +import type { FulfillmentOrderItem } from "@customer-portal/contracts/orders"; +import type { WhmcsOrderItem } from "@bff/integrations/whmcs/services/whmcs-order.service"; +import { mapFulfillmentOrderItem, mapFulfillmentOrderItems } from "@customer-portal/integrations-whmcs/mappers"; export interface OrderItemMappingResult { whmcsItems: WhmcsOrderItem[]; @@ -35,30 +36,7 @@ export class OrderWhmcsMapper { } try { - const whmcsItems: WhmcsOrderItem[] = []; - let serviceItems = 0; - let activationItems = 0; - - for (const [index, item] of orderItems.entries()) { - const whmcsItem = this.mapSingleOrderItem(item, index); - whmcsItems.push(whmcsItem); - - // Track item types for summary - if (whmcsItem.billingCycle === "monthly") { - serviceItems++; - } else if (whmcsItem.billingCycle === "onetime") { - activationItems++; - } - } - - const result: OrderItemMappingResult = { - whmcsItems, - summary: { - totalItems: whmcsItems.length, - serviceItems, - activationItems, - }, - }; + const result = mapFulfillmentOrderItems(orderItems); this.logger.log("OrderItems mapping completed successfully", { totalItems: result.summary.totalItems, @@ -80,38 +58,22 @@ export class OrderWhmcsMapper { * Map a single Salesforce OrderItem to WHMCS format */ private mapSingleOrderItem(item: FulfillmentOrderItem, index: number): WhmcsOrderItem { - const product = item.product; // This is the transformed structure from OrderOrchestrator + try { + const whmcsItem = mapFulfillmentOrderItem(item, index); - if (!product) { - throw new BadRequestException(`OrderItem ${index} missing product information`); + this.logger.log("Mapped single OrderItem to WHMCS", { + index, + sfProductId: item.product?.id, + whmcsProductId: item.product?.whmcsProductId, + billingCycle: item.product?.billingCycle, + quantity: whmcsItem.quantity, + }); + + return whmcsItem; + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + throw new BadRequestException(message); } - - if (!product.whmcsProductId) { - throw new BadRequestException( - `Product ${product.id} missing WHMCS Product ID mapping (whmcsProductId)` - ); - } - - if (!product.billingCycle) { - throw new BadRequestException(`Product ${product.id} missing billing cycle`); - } - - // Build WHMCS item - WHMCS products already have their billing cycles configured - const whmcsItem: WhmcsOrderItem = { - productId: product.whmcsProductId, - billingCycle: product.billingCycle.toLowerCase(), - quantity: item.quantity || 1, - }; - - this.logger.log("Mapped single OrderItem to WHMCS", { - index, - sfProductId: product.id, - whmcsProductId: product.whmcsProductId, - billingCycle: product.billingCycle, - quantity: whmcsItem.quantity, - }); - - return whmcsItem; } /** diff --git a/apps/bff/src/modules/subscriptions/sim-management.service.ts b/apps/bff/src/modules/subscriptions/sim-management.service.ts index a1fc6bad..e8283e00 100644 --- a/apps/bff/src/modules/subscriptions/sim-management.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management.service.ts @@ -1,11 +1,7 @@ import { Injectable } from "@nestjs/common"; import { SimOrchestratorService } from "./sim-management/services/sim-orchestrator.service"; import { SimNotificationService } from "./sim-management/services/sim-notification.service"; -import type { - SimDetails, - SimUsage, - SimTopUpHistory, -} from "@bff/integrations/freebit/interfaces/freebit.types"; +import type { SimDetails, SimTopUpHistory, SimUsage } from "@customer-portal/contracts/sim"; import type { SimTopUpRequest, SimPlanChangeRequest, diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-details.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-details.service.ts index 679d63cd..a2ce3550 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-details.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-details.service.ts @@ -3,7 +3,7 @@ import { Logger } from "nestjs-pino"; import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service"; import { SimValidationService } from "./sim-validation.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import type { SimDetails } from "@bff/integrations/freebit/interfaces/freebit.types"; +import type { SimDetails } from "@customer-portal/contracts/sim"; @Injectable() export class SimDetailsService { diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts index 7a6c4361..dcbf49a9 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-orchestrator.service.ts @@ -8,11 +8,7 @@ import { SimCancellationService } from "./sim-cancellation.service"; import { EsimManagementService } from "./esim-management.service"; import { SimValidationService } from "./sim-validation.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import type { - SimDetails, - SimUsage, - SimTopUpHistory, -} from "@bff/integrations/freebit/interfaces/freebit.types"; +import type { SimDetails, SimTopUpHistory, SimUsage } from "@customer-portal/contracts/sim"; import type { SimTopUpRequest, SimPlanChangeRequest, diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts index 6d50870c..a6ff7bda 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts @@ -4,7 +4,7 @@ import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/f import { SimValidationService } from "./sim-validation.service"; import { SimUsageStoreService } from "../../sim-usage-store.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; -import type { SimUsage, SimTopUpHistory } from "@bff/integrations/freebit/interfaces/freebit.types"; +import type { SimTopUpHistory, SimUsage } from "@customer-portal/contracts/sim"; import type { SimTopUpHistoryRequest } from "../types/sim-requests.types"; import { BadRequestException } from "@nestjs/common"; diff --git a/apps/bff/tsconfig.json b/apps/bff/tsconfig.json index 6e99c21a..264c3595 100644 --- a/apps/bff/tsconfig.json +++ b/apps/bff/tsconfig.json @@ -14,6 +14,14 @@ "@bff/integrations/*": ["src/integrations/*"], "@customer-portal/domain": ["../../packages/domain/src"], "@customer-portal/domain/*": ["../../packages/domain/src/*"], + "@customer-portal/contracts": ["../../packages/contracts/src"], + "@customer-portal/contracts/*": ["../../packages/contracts/src/*"], + "@customer-portal/schemas": ["../../packages/schemas/src"], + "@customer-portal/schemas/*": ["../../packages/schemas/src/*"], + "@customer-portal/integrations-whmcs": ["../../packages/integrations/whmcs/src"], + "@customer-portal/integrations-whmcs/*": ["../../packages/integrations/whmcs/src/*"], + "@customer-portal/integrations-freebit": ["../../packages/integrations/freebit/src"], + "@customer-portal/integrations-freebit/*": ["../../packages/integrations/freebit/src/*"], "@customer-portal/validation": ["../../packages/validation/src"], "@customer-portal/validation/*": ["../../packages/validation/src/*"], "@customer-portal/logging": ["../../packages/logging/src"], diff --git a/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx b/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx index a7ea4376..aaae2e6a 100644 --- a/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx +++ b/apps/portal/src/features/sim-management/components/SimDetailsCard.tsx @@ -11,33 +11,16 @@ import { ExclamationTriangleIcon, XCircleIcon, } from "@heroicons/react/24/outline"; +import type { SimDetails as SimDetailsContract } from "@customer-portal/contracts/sim"; -export interface SimDetails { - account: string; - msisdn: string; - iccid?: string; - imsi?: string; - eid?: string; - planCode: string; - status: "active" | "suspended" | "cancelled" | "pending"; - simType: "physical" | "esim"; - size: "standard" | "nano" | "micro" | "esim"; - hasVoice: boolean; - hasSms: boolean; - remainingQuotaKb: number; - remainingQuotaMb: number; - startDate?: string; +export type SimDetails = SimDetailsContract & { + size?: "standard" | "nano" | "micro" | "esim"; + hasVoice?: boolean; + hasSms?: boolean; ipv4?: string; ipv6?: string; - voiceMailEnabled?: boolean; - callWaitingEnabled?: boolean; - internationalRoamingEnabled?: boolean; - networkType?: string; - pendingOperations?: Array<{ - operation: string; - scheduledDate: string; - }>; -} + pendingOperations?: Array<{ operation: string; scheduledDate: string }>; +}; interface SimDetailsCardProps { simDetails: SimDetails; @@ -55,6 +38,15 @@ export function SimDetailsCard({ showFeaturesSummary = true, }: SimDetailsCardProps) { const formatPlan = (code?: string) => formatPlanShort(code); + const isEsim = simDetails.simType === "esim"; + const hasVoice = Boolean(simDetails.hasVoice ?? simDetails.voiceMailEnabled); + const hasSms = Boolean(simDetails.hasSms); + const voiceMailEnabled = Boolean(simDetails.voiceMailEnabled); + const callWaitingEnabled = Boolean(simDetails.callWaitingEnabled); + const internationalRoamingEnabled = Boolean(simDetails.internationalRoamingEnabled); + const sizeLabel = simDetails.size ?? simDetails.simType; + const ipv4Address = simDetails.ipv4; + const ipv6Address = simDetails.ipv6; const getStatusIcon = (status: string) => { switch (status) { case "active": @@ -211,25 +203,25 @@ export function SimDetailsCard({
Voice Mail (¥300/month) - {simDetails.voiceMailEnabled ? "Enabled" : "Disabled"} + {voiceMailEnabled ? "Enabled" : "Disabled"}
Call Waiting (¥300/month) - {simDetails.callWaitingEnabled ? "Enabled" : "Disabled"} + {callWaitingEnabled ? "Enabled" : "Disabled"}
International Roaming - {simDetails.internationalRoamingEnabled ? "Enabled" : "Disabled"} + {internationalRoamingEnabled ? "Enabled" : "Disabled"}
@@ -259,7 +251,7 @@ export function SimDetailsCard({

Physical SIM Details

- {formatPlan(simDetails.planCode)} • {`${simDetails.size} SIM`} + {formatPlan(simDetails.planCode)} • {`${sizeLabel ?? "Unknown"} SIM`}

@@ -335,22 +327,18 @@ export function SimDetailsCard({
- - Voice {simDetails.hasVoice ? "Enabled" : "Disabled"} + + Voice {hasVoice ? "Enabled" : "Disabled"}
- - SMS {simDetails.hasSms ? "Enabled" : "Disabled"} + + SMS {hasSms ? "Enabled" : "Disabled"}
@@ -359,11 +347,14 @@ export function SimDetailsCard({
- {simDetails.ipv4 && ( -

IPv4: {simDetails.ipv4}

+ {ipv4Address && ( +

IPv4: {ipv4Address}

)} - {simDetails.ipv6 && ( -

IPv6: {simDetails.ipv6}

+ {ipv6Address && ( +

IPv6: {ipv6Address}

+ )} + {!ipv4Address && !ipv6Address && ( +

No IP assigned

)}
diff --git a/apps/portal/tsconfig.json b/apps/portal/tsconfig.json index e5e609ec..3748bd09 100644 --- a/apps/portal/tsconfig.json +++ b/apps/portal/tsconfig.json @@ -18,6 +18,10 @@ "@/lib/*": ["./src/lib/*"], "@customer-portal/domain": ["../../packages/domain/src"], "@customer-portal/domain/*": ["../../packages/domain/src/*"], + "@customer-portal/contracts": ["../../packages/contracts/src"], + "@customer-portal/contracts/*": ["../../packages/contracts/src/*"], + "@customer-portal/schemas": ["../../packages/schemas/src"], + "@customer-portal/schemas/*": ["../../packages/schemas/src/*"], "@customer-portal/logging": ["../../packages/logging/src"], "@customer-portal/logging/*": ["../../packages/logging/src/*"], "@customer-portal/validation": ["../../packages/validation/src"], diff --git a/packages/contracts/package.json b/packages/contracts/package.json new file mode 100644 index 00000000..f8e6d3c6 --- /dev/null +++ b/packages/contracts/package.json @@ -0,0 +1,54 @@ +{ + "name": "@customer-portal/contracts", + "version": "0.1.0", + "description": "Shared domain contracts (types only, no runtime dependencies).", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "private": true, + "sideEffects": false, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./billing": { + "types": "./dist/billing/index.d.ts", + "default": "./dist/billing/index.js" + }, + "./subscriptions": { + "types": "./dist/subscriptions/index.d.ts", + "default": "./dist/subscriptions/index.js" + }, + "./payments": { + "types": "./dist/payments/index.d.ts", + "default": "./dist/payments/index.js" + }, + "./sim": { + "types": "./dist/sim/index.d.ts", + "default": "./dist/sim/index.js" + }, + "./orders": { + "types": "./dist/orders/index.d.ts", + "default": "./dist/orders/index.js" + }, + "./freebit": { + "types": "./dist/freebit/index.d.ts", + "default": "./dist/freebit/index.js" + } + }, + "scripts": { + "build": "tsc -b", + "dev": "tsc -b -w --preserveWatchOutput", + "clean": "rm -rf dist", + "type-check": "tsc --project tsconfig.json --noEmit", + "test": "echo \"No tests for contracts package\"", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "devDependencies": { + "typescript": "^5.9.2" + } +} diff --git a/packages/contracts/src/billing/index.ts b/packages/contracts/src/billing/index.ts new file mode 100644 index 00000000..c759eb69 --- /dev/null +++ b/packages/contracts/src/billing/index.ts @@ -0,0 +1 @@ +export * from "./invoice"; diff --git a/packages/contracts/src/billing/invoice.ts b/packages/contracts/src/billing/invoice.ts new file mode 100644 index 00000000..f70191c1 --- /dev/null +++ b/packages/contracts/src/billing/invoice.ts @@ -0,0 +1,49 @@ +export type InvoiceStatus = + | "Draft" + | "Pending" + | "Paid" + | "Unpaid" + | "Overdue" + | "Cancelled" + | "Refunded" + | "Collections"; + +export interface InvoiceItem { + id: number; + description: string; + amount: number; + quantity?: number; + type: string; + serviceId?: number; +} + +export interface Invoice { + id: number; + number: string; + status: InvoiceStatus; + currency: string; + currencySymbol?: string; + total: number; + subtotal: number; + tax: number; + issuedAt?: string; + dueDate?: string; + paidDate?: string; + pdfUrl?: string; + paymentUrl?: string; + description?: string; + items?: InvoiceItem[]; + daysOverdue?: number; +} + +export interface InvoicePagination { + page: number; + totalPages: number; + totalItems: number; + nextCursor?: string; +} + +export interface InvoiceList { + invoices: Invoice[]; + pagination: InvoicePagination; +} diff --git a/packages/contracts/src/freebit/index.ts b/packages/contracts/src/freebit/index.ts new file mode 100644 index 00000000..415726b7 --- /dev/null +++ b/packages/contracts/src/freebit/index.ts @@ -0,0 +1 @@ +export * from "./requests"; diff --git a/packages/contracts/src/freebit/requests.ts b/packages/contracts/src/freebit/requests.ts new file mode 100644 index 00000000..42ae8685 --- /dev/null +++ b/packages/contracts/src/freebit/requests.ts @@ -0,0 +1,46 @@ +export interface FreebitAccountDetailsRequest { + version?: string; + requestDatas: Array<{ kind: "MASTER" | "MVNO"; account?: string | number }>; +} + +export interface FreebitTrafficInfoRequest { + account: string; +} + +export interface FreebitTopUpOptions { + campaignCode?: string; + expiryDate?: string; + scheduledAt?: string; +} + +export interface FreebitTopUpRequestPayload { + account: string; + quotaMb: number; + options?: FreebitTopUpOptions; +} + +export interface FreebitPlanChangeRequestData { + account: string; + newPlanCode: string; + assignGlobalIp?: boolean; + scheduledAt?: string; +} + +export interface FreebitAddSpecRequestData { + account: string; + specCode: string; + enabled?: boolean; + networkType?: "4G" | "5G"; +} + +export interface FreebitCancelPlanRequestData { + account: string; + runDate: string; +} + +export interface FreebitEsimReissueRequestData { + account: string; + newEid: string; + oldEid?: string; + planCode?: string; +} diff --git a/packages/contracts/src/index.ts b/packages/contracts/src/index.ts new file mode 100644 index 00000000..1a3c7fd3 --- /dev/null +++ b/packages/contracts/src/index.ts @@ -0,0 +1,13 @@ +export * as BillingContracts from "./billing"; +export * as SubscriptionContracts from "./subscriptions"; +export * as PaymentContracts from "./payments"; +export * as SimContracts from "./sim"; +export * as OrderContracts from "./orders"; +export * as FreebitContracts from "./freebit"; + +export * from "./billing"; +export * from "./subscriptions"; +export * from "./payments"; +export * from "./sim"; +export * from "./orders"; +export * from "./freebit"; diff --git a/packages/contracts/src/orders/fulfillment.ts b/packages/contracts/src/orders/fulfillment.ts new file mode 100644 index 00000000..5af6a0e6 --- /dev/null +++ b/packages/contracts/src/orders/fulfillment.ts @@ -0,0 +1,20 @@ +export interface FulfillmentOrderProduct { + id?: string; + sku?: string; + itemClass?: string; + whmcsProductId?: string; + billingCycle?: string; +} + +export interface FulfillmentOrderItem { + id: string; + orderId: string; + quantity: number; + product: FulfillmentOrderProduct | null; +} + +export interface FulfillmentOrderDetails { + id: string; + orderType?: string; + items: FulfillmentOrderItem[]; +} diff --git a/packages/contracts/src/orders/index.ts b/packages/contracts/src/orders/index.ts new file mode 100644 index 00000000..a9453382 --- /dev/null +++ b/packages/contracts/src/orders/index.ts @@ -0,0 +1 @@ +export * from "./fulfillment"; diff --git a/packages/contracts/src/payments/index.ts b/packages/contracts/src/payments/index.ts new file mode 100644 index 00000000..d2e00b54 --- /dev/null +++ b/packages/contracts/src/payments/index.ts @@ -0,0 +1 @@ +export * from "./payment"; diff --git a/packages/contracts/src/payments/payment.ts b/packages/contracts/src/payments/payment.ts new file mode 100644 index 00000000..50d394c6 --- /dev/null +++ b/packages/contracts/src/payments/payment.ts @@ -0,0 +1,44 @@ +export type PaymentMethodType = + | "CreditCard" + | "BankAccount" + | "RemoteCreditCard" + | "RemoteBankAccount" + | "Manual"; + +export interface PaymentMethod { + id: number; + type: PaymentMethodType; + description: string; + gatewayName?: string; + contactType?: string; + contactId?: number; + cardLastFour?: string; + expiryDate?: string; + startDate?: string; + issueNumber?: string; + cardType?: string; + remoteToken?: string; + lastUpdated?: string; + bankName?: string; + isDefault?: boolean; +} + +export interface PaymentMethodList { + paymentMethods: PaymentMethod[]; + totalCount: number; +} + +export type PaymentGatewayType = "merchant" | "thirdparty" | "tokenization" | "manual"; + +export interface PaymentGateway { + name: string; + displayName: string; + type: PaymentGatewayType; + isActive: boolean; + configuration?: Record; +} + +export interface PaymentGatewayList { + gateways: PaymentGateway[]; + totalCount: number; +} diff --git a/packages/contracts/src/sim/index.ts b/packages/contracts/src/sim/index.ts new file mode 100644 index 00000000..eea524d6 --- /dev/null +++ b/packages/contracts/src/sim/index.ts @@ -0,0 +1 @@ +export * from "./types"; diff --git a/packages/contracts/src/sim/types.ts b/packages/contracts/src/sim/types.ts new file mode 100644 index 00000000..e747b104 --- /dev/null +++ b/packages/contracts/src/sim/types.ts @@ -0,0 +1,54 @@ +export type SimStatus = "active" | "suspended" | "cancelled" | "pending"; +export type SimType = "standard" | "nano" | "micro" | "esim"; + +export interface SimDetails { + account: string; + status: SimStatus; + planCode: string; + planName: string; + simType: SimType; + iccid: string; + eid: string; + msisdn: string; + imsi: string; + remainingQuotaMb: number; + remainingQuotaKb: number; + voiceMailEnabled: boolean; + callWaitingEnabled: boolean; + internationalRoamingEnabled: boolean; + networkType: string; + activatedAt?: string; + expiresAt?: string; +} + +export interface RecentDayUsage { + date: string; + usageKb: number; + usageMb: number; +} + +export interface SimUsage { + account: string; + todayUsageMb: number; + todayUsageKb: number; + monthlyUsageMb?: number; + monthlyUsageKb?: number; + recentDaysUsage: RecentDayUsage[]; + isBlacklisted: boolean; + lastUpdated?: string; +} + +export interface SimTopUpHistoryEntry { + quotaKb: number; + quotaMb: number; + addedDate: string; + expiryDate: string; + campaignCode: string; +} + +export interface SimTopUpHistory { + account: string; + totalAdditions: number; + additionCount: number; + history: SimTopUpHistoryEntry[]; +} diff --git a/packages/contracts/src/subscriptions/index.ts b/packages/contracts/src/subscriptions/index.ts new file mode 100644 index 00000000..75c2cf41 --- /dev/null +++ b/packages/contracts/src/subscriptions/index.ts @@ -0,0 +1 @@ +export * from "./subscription"; diff --git a/packages/contracts/src/subscriptions/subscription.ts b/packages/contracts/src/subscriptions/subscription.ts new file mode 100644 index 00000000..bb6f587c --- /dev/null +++ b/packages/contracts/src/subscriptions/subscription.ts @@ -0,0 +1,43 @@ +export type SubscriptionStatus = + | "Active" + | "Inactive" + | "Pending" + | "Cancelled" + | "Suspended" + | "Terminated" + | "Completed"; + +export type SubscriptionCycle = + | "Monthly" + | "Quarterly" + | "Semi-Annually" + | "Annually" + | "Biennially" + | "Triennially" + | "One-time" + | "Free"; + +export interface Subscription { + id: number; + serviceId: number; + productName: string; + domain?: string; + cycle: SubscriptionCycle; + status: SubscriptionStatus; + nextDue?: string; + amount: number; + currency: string; + currencySymbol?: string; + registrationDate: string; + notes?: string; + customFields?: Record; + orderNumber?: string; + groupName?: string; + paymentMethod?: string; + serverName?: string; +} + +export interface SubscriptionList { + subscriptions: Subscription[]; + totalCount: number; +} diff --git a/packages/contracts/tsconfig.json b/packages/contracts/tsconfig.json new file mode 100644 index 00000000..df143c4f --- /dev/null +++ b/packages/contracts/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "nodenext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "composite": true, + "tsBuildInfoFile": "./tsconfig.tsbuildinfo" + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/packages/domain/package.json b/packages/domain/package.json index da5f2be5..a8639b07 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -28,6 +28,8 @@ "typescript": "^5.9.2" }, "dependencies": { + "@customer-portal/contracts": "workspace:*", + "@customer-portal/schemas": "workspace:*", "zod": "^4.1.9" } } diff --git a/packages/domain/src/entities/invoice.ts b/packages/domain/src/entities/invoice.ts index 938d8f75..c2592f66 100644 --- a/packages/domain/src/entities/invoice.ts +++ b/packages/domain/src/entities/invoice.ts @@ -1,9 +1,21 @@ -// Invoice types from WHMCS -import type { InvoiceSchema, InvoiceItemSchema, InvoiceListSchema } from "../validation"; +import type { + Invoice as InvoiceContract, + InvoiceItem as InvoiceItemContract, + InvoiceList as InvoiceListContract, +} from "@customer-portal/contracts/billing"; +import type { + InvoiceSchema as InvoiceSchemaType, + InvoiceItemSchema as InvoiceItemSchemaType, + InvoiceListSchema as InvoiceListSchemaType, +} from "@customer-portal/schemas/billing/invoice.schema"; -export type Invoice = InvoiceSchema; -export type InvoiceItem = InvoiceItemSchema; -export type InvoiceList = InvoiceListSchema; +export type Invoice = InvoiceContract; +export type InvoiceItem = InvoiceItemContract; +export type InvoiceList = InvoiceListContract; + +export type InvoiceSchema = InvoiceSchemaType; +export type InvoiceItemSchema = InvoiceItemSchemaType; +export type InvoiceListSchema = InvoiceListSchemaType; export interface InvoiceSsoLink { url: string; diff --git a/packages/domain/src/entities/payment.ts b/packages/domain/src/entities/payment.ts index f1976adc..49164ad1 100644 --- a/packages/domain/src/entities/payment.ts +++ b/packages/domain/src/entities/payment.ts @@ -1,27 +1,22 @@ // Payment method types for WHMCS integration -import type { WhmcsEntity } from "../common"; +import type { + PaymentGateway as PaymentGatewayContract, + PaymentGatewayList as PaymentGatewayListContract, + PaymentMethod as PaymentMethodContract, + PaymentMethodList as PaymentMethodListContract, +} from "@customer-portal/contracts/payments"; +import type { + PaymentGatewaySchema as PaymentGatewaySchemaType, + PaymentMethodSchema as PaymentMethodSchemaType, +} from "@customer-portal/schemas/payments/payment.schema"; -export interface PaymentMethod extends WhmcsEntity { - type: "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount" | "Manual"; - description: string; - gatewayName?: string; - contactType?: string; - contactId?: number; - cardLastFour?: string; - expiryDate?: string; - startDate?: string; - issueNumber?: string; - cardType?: string; - remoteToken?: string; - lastUpdated?: string; - bankName?: string; - isDefault?: boolean; -} +export type PaymentMethod = PaymentMethodContract; +export type PaymentMethodList = PaymentMethodListContract; +export type PaymentGateway = PaymentGatewayContract; +export type PaymentGatewayList = PaymentGatewayListContract; -export interface PaymentMethodList { - paymentMethods: PaymentMethod[]; - totalCount: number; -} +export type PaymentMethodSchema = PaymentMethodSchemaType; +export type PaymentGatewaySchema = PaymentGatewaySchemaType; export interface CreatePaymentMethodRequest { type: "CreditCard" | "BankAccount" | "RemoteCreditCard"; @@ -45,19 +40,6 @@ export interface CreatePaymentMethodRequest { billingContactId?: number; } -export interface PaymentGateway { - name: string; - displayName: string; - type: "merchant" | "thirdparty" | "tokenization" | "manual"; - isActive: boolean; - configuration?: Record; -} - -export interface PaymentGatewayList { - gateways: PaymentGateway[]; - totalCount: number; -} - export interface InvoicePaymentLink { url: string; expiresAt: string; diff --git a/packages/domain/src/entities/subscription.ts b/packages/domain/src/entities/subscription.ts index 2f9b3c61..cc8339f0 100644 --- a/packages/domain/src/entities/subscription.ts +++ b/packages/domain/src/entities/subscription.ts @@ -1,8 +1,14 @@ -import type { SubscriptionSchema } from "../validation"; +import type { + Subscription as SubscriptionContract, + SubscriptionList as SubscriptionListContract, +} from "@customer-portal/contracts/subscriptions"; +import type { + SubscriptionSchema as SubscriptionSchemaType, + SubscriptionListSchema as SubscriptionListSchemaType, +} from "@customer-portal/schemas/subscriptions/subscription.schema"; -export type Subscription = SubscriptionSchema; +export type Subscription = SubscriptionContract; +export type SubscriptionList = SubscriptionListContract; -export interface SubscriptionList { - subscriptions: Subscription[]; - totalCount: number; -} +export type SubscriptionSchema = SubscriptionSchemaType; +export type SubscriptionListSchema = SubscriptionListSchemaType; diff --git a/packages/domain/src/validation/shared/entities.ts b/packages/domain/src/validation/shared/entities.ts index 71b8b09e..4a4c1385 100644 --- a/packages/domain/src/validation/shared/entities.ts +++ b/packages/domain/src/validation/shared/entities.ts @@ -4,6 +4,23 @@ */ import { z } from "zod"; + +import { + invoiceItemSchema as billingInvoiceItemSchema, + invoiceListSchema as billingInvoiceListSchema, + invoiceSchema as billingInvoiceSchema, +} from "@customer-portal/schemas/billing/invoice.schema"; +import { + subscriptionCycleSchema as sharedSubscriptionCycleSchema, + subscriptionListSchema as sharedSubscriptionListSchema, + subscriptionSchema as sharedSubscriptionSchema, + subscriptionStatusSchema as sharedSubscriptionStatusSchema, +} from "@customer-portal/schemas/subscriptions/subscription.schema"; +import { + paymentGatewaySchema as sharedPaymentGatewaySchema, + paymentMethodSchema as sharedPaymentMethodSchema, + paymentMethodTypeSchema as sharedPaymentMethodTypeSchema, +} from "@customer-portal/schemas/payments/payment.schema"; import { emailSchema, nameSchema, @@ -47,32 +64,17 @@ const addressRecordSchema = z.object({ country: z.string().nullable(), }); -const paymentMethodTypeSchema = z.enum([ - "CreditCard", - "BankAccount", - "RemoteCreditCard", - "RemoteBankAccount", - "Manual", -]); +export const paymentMethodTypeSchema = sharedPaymentMethodTypeSchema; export const orderStatusSchema = z.enum(["Pending", "Active", "Cancelled", "Fraud"]); export const invoiceStatusSchema = z.enum(tupleFromEnum(INVOICE_STATUS)); -export const subscriptionStatusSchema = z.enum(tupleFromEnum(SUBSCRIPTION_STATUS)); +export const subscriptionStatusSchema = sharedSubscriptionStatusSchema; export const caseStatusSchema = z.enum(tupleFromEnum(CASE_STATUS)); export const casePrioritySchema = z.enum(tupleFromEnum(CASE_PRIORITY)); export const paymentStatusSchema = z.enum(tupleFromEnum(PAYMENT_STATUS)); export const caseTypeSchema = z.enum(["Question", "Problem", "Feature Request"]); -export const subscriptionCycleSchema = z.enum([ - "Monthly", - "Quarterly", - "Semi-Annually", - "Annually", - "Biennially", - "Triennially", - "One-time", - "Free", -]); +export const subscriptionCycleSchema = sharedSubscriptionCycleSchema; // ===================================================== // USER ENTITIES @@ -152,86 +154,25 @@ export const whmcsOrderSchema = whmcsEntitySchema.extend({ // INVOICE ENTITIES (WHMCS) // ===================================================== -export const invoiceItemSchema = z.object({ - id: z.number().int().positive("Invoice item id must be positive"), - description: z.string().min(1, "Description is required"), - amount: z.number(), - quantity: z.number().int().positive("Quantity must be positive").optional(), - type: z.string().min(1, "Item type is required"), - serviceId: z.number().int().positive().optional(), -}); +export const invoiceItemSchema = billingInvoiceItemSchema; -export const invoiceSchema = whmcsEntitySchema.extend({ - number: z.string().min(1, "Invoice number is required"), - status: invoiceStatusSchema, - currency: z.string().min(1, "Currency is required"), - currencySymbol: z.string().min(1, "Currency symbol is required").optional(), - total: z.number(), - subtotal: z.number(), - tax: z.number(), - issuedAt: z.string().optional(), - dueDate: z.string().optional(), - paidDate: z.string().optional(), - pdfUrl: z.string().optional(), - paymentUrl: z.string().optional(), - description: z.string().optional(), - items: z.array(invoiceItemSchema).optional(), - daysOverdue: z.number().int().nonnegative().optional(), -}); +export const invoiceSchema = billingInvoiceSchema; -export const invoiceListSchema = z.object({ - invoices: z.array(invoiceSchema), - pagination: z.object({ - page: z.number().int().nonnegative(), - totalPages: z.number().int().nonnegative(), - totalItems: z.number().int().nonnegative(), - nextCursor: z.string().optional(), - }), -}); +export const invoiceListSchema = billingInvoiceListSchema; // ===================================================== // SUBSCRIPTION ENTITIES (WHMCS) // ===================================================== -export const subscriptionSchema = whmcsEntitySchema.extend({ - serviceId: z.number().int().positive("Service id is required"), - productName: z.string().min(1, "Product name is required"), - domain: z.string().optional(), - cycle: subscriptionCycleSchema, - status: subscriptionStatusSchema, - nextDue: z.string().optional(), - amount: z.number(), - currency: z.string().min(1, "Currency is required"), - currencySymbol: z.string().optional(), - registrationDate: z.string().min(1, "Registration date is required"), - notes: z.string().optional(), - customFields: z.record(z.string(), z.string()).optional(), - orderNumber: z.string().optional(), - groupName: z.string().optional(), - paymentMethod: z.string().optional(), - serverName: z.string().optional(), -}); +export const subscriptionSchema = sharedSubscriptionSchema; +export const subscriptionListSchema = sharedSubscriptionListSchema; // ===================================================== // PAYMENT ENTITIES (WHMCS & PORTAL) // ===================================================== -export const paymentMethodSchema = whmcsEntitySchema.extend({ - type: paymentMethodTypeSchema, - description: z.string().min(1, "Payment method description is required"), - gatewayName: z.string().optional(), - contactType: z.string().optional(), - contactId: z.number().int().positive().optional(), - cardLastFour: z.string().length(4, "Last four must be exactly 4 digits").optional(), - expiryDate: z.string().optional(), - startDate: z.string().optional(), - issueNumber: z.string().optional(), - cardType: z.string().optional(), - remoteToken: z.string().optional(), - lastUpdated: z.string().optional(), - bankName: z.string().optional(), - isDefault: z.boolean().optional(), -}); +export const paymentMethodSchema = sharedPaymentMethodSchema; +export const paymentGatewaySchema = sharedPaymentGatewaySchema; export const paymentSchema = z.object({ id: paymentIdSchema, diff --git a/packages/domain/tsconfig.json b/packages/domain/tsconfig.json index 5ca8b37e..512780a0 100644 --- a/packages/domain/tsconfig.json +++ b/packages/domain/tsconfig.json @@ -10,7 +10,12 @@ "declarationMap": true, "composite": true, "tsBuildInfoFile": "./tsconfig.tsbuildinfo", - "paths": {} + "paths": { + "@customer-portal/contracts": ["../contracts/src"], + "@customer-portal/contracts/*": ["../contracts/src/*"], + "@customer-portal/schemas": ["../schemas/src"], + "@customer-portal/schemas/*": ["../schemas/src/*"] + } }, "include": ["src/**/*"], "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] diff --git a/packages/integrations/freebit/package.json b/packages/integrations/freebit/package.json new file mode 100644 index 00000000..53b57c05 --- /dev/null +++ b/packages/integrations/freebit/package.json @@ -0,0 +1,38 @@ +{ + "name": "@customer-portal/integrations-freebit", + "version": "0.1.0", + "description": "Freebit integration helpers (mappers, utilities).", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "private": true, + "sideEffects": false, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./mappers": { + "types": "./dist/mappers/index.d.ts", + "default": "./dist/mappers/index.js" + } + }, + "scripts": { + "build": "tsc -b", + "dev": "tsc -b -w --preserveWatchOutput", + "clean": "rm -rf dist", + "type-check": "tsc --project tsconfig.json --noEmit", + "test": "echo \"No tests for Freebit integration package\"", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "devDependencies": { + "typescript": "^5.9.2" + }, + "dependencies": { + "@customer-portal/contracts": "workspace:*", + "@customer-portal/schemas": "workspace:*" + } +} diff --git a/packages/integrations/freebit/src/index.ts b/packages/integrations/freebit/src/index.ts new file mode 100644 index 00000000..1d8840b1 --- /dev/null +++ b/packages/integrations/freebit/src/index.ts @@ -0,0 +1,4 @@ +export * as FreebitMappers from "./mappers"; +export * as FreebitUtils from "./utils"; +export * from "./mappers"; +export * from "./utils"; diff --git a/packages/integrations/freebit/src/mappers/index.ts b/packages/integrations/freebit/src/mappers/index.ts new file mode 100644 index 00000000..bf7330cc --- /dev/null +++ b/packages/integrations/freebit/src/mappers/index.ts @@ -0,0 +1 @@ +export * from "./sim.mapper"; diff --git a/packages/integrations/freebit/src/mappers/sim.mapper.ts b/packages/integrations/freebit/src/mappers/sim.mapper.ts new file mode 100644 index 00000000..55b366a4 --- /dev/null +++ b/packages/integrations/freebit/src/mappers/sim.mapper.ts @@ -0,0 +1,133 @@ +import type { + SimDetails, + SimTopUpHistory, + SimUsage, +} from "@customer-portal/contracts/sim"; +import { + simDetailsSchema, + simTopUpHistorySchema, + simUsageSchema, +} from "@customer-portal/schemas/sim/sim.schema"; +import { + freebitAccountDetailsResponseSchema, +} from "@customer-portal/schemas/integrations/freebit/account.schema"; +import { + freebitTrafficInfoSchema, +} from "@customer-portal/schemas/integrations/freebit/traffic.schema"; +import { + freebitQuotaHistoryResponseSchema, +} from "@customer-portal/schemas/integrations/freebit/quota.schema"; + +import { asString, formatIsoDate, parseBooleanFlag, parseNumber } from "../utils/data-utils"; + +function mapSimStatus(status: string | undefined): SimDetails["status"] { + if (!status) return "pending"; + const normalized = status.toLowerCase(); + if (normalized === "active") return "active"; + if (normalized === "suspended" || normalized === "temporary") return "suspended"; + if (normalized === "obsolete" || normalized === "cancelled" || normalized === "canceled") { + return "cancelled"; + } + return "pending"; +} + +function deriveSimType(detail: unknown, eid?: string | null): SimDetails["simType"] { + const raw = typeof detail === "string" ? detail.toLowerCase() : undefined; + if (eid) { + return "esim"; + } + switch (raw) { + case "nano": + return "nano"; + case "micro": + return "micro"; + case "esim": + return "esim"; + default: + return "standard"; + } +} + +export function transformFreebitAccountDetails(raw: unknown): SimDetails { + const response = freebitAccountDetailsResponseSchema.parse(raw); + const account = response.responseDatas.at(0); + if (!account) { + throw new Error("Freebit account details missing response data"); + } + + const sanitizedAccount = asString(account.account); + const simType = deriveSimType(account.simSize ?? account.size, account.eid); + const voiceMailEnabled = parseBooleanFlag(account.voicemail ?? account.voiceMail); + const callWaitingEnabled = parseBooleanFlag(account.callwaiting ?? account.callWaiting); + const internationalRoamingEnabled = parseBooleanFlag(account.worldwing ?? account.worldWing); + + const simDetails: SimDetails = { + account: sanitizedAccount, + status: mapSimStatus(asString(account.state ?? account.status)), + planCode: asString(account.planCode), + planName: asString(account.planName), + simType, + iccid: asString(account.iccid), + eid: asString(account.eid), + msisdn: asString(account.msisdn ?? account.account), + imsi: asString(account.imsi), + remainingQuotaMb: parseNumber(account.remainingQuotaMb), + remainingQuotaKb: parseNumber(account.remainingQuotaKb), + voiceMailEnabled, + callWaitingEnabled, + internationalRoamingEnabled, + networkType: asString(account.contractLine ?? account.simType ?? "4G"), + activatedAt: formatIsoDate(account.startDate), + expiresAt: formatIsoDate(account.async?.date), + }; + + return simDetailsSchema.parse(simDetails); +} + +export function transformFreebitTrafficInfo(raw: unknown): SimUsage { + const response = freebitTrafficInfoSchema.parse(raw); + const todayUsageKb = parseNumber(response.traffic.today); + const recentDaysUsage = response.traffic.inRecentDays + .split(",") + .map((usage, index) => { + const usageKb = parseNumber(usage); + const date = new Date(); + date.setDate(date.getDate() - (index + 1)); + return { + date: date.toISOString().split("T")[0], + usageKb, + usageMb: Math.round((usageKb / 1024) * 100) / 100, + }; + }); + + const simUsage: SimUsage = { + account: response.account, + todayUsageKb, + todayUsageMb: Math.round((todayUsageKb / 1024) * 100) / 100, + recentDaysUsage, + isBlacklisted: response.traffic.blackList === "10", + }; + + return simUsageSchema.parse(simUsage); +} + +export function transformFreebitQuotaHistory(raw: unknown, account: string): SimTopUpHistory { + const response = freebitQuotaHistoryResponseSchema.parse(raw); + + const history = response.quotaHistory.map(entry => ({ + quotaKb: parseNumber(entry.quota), + quotaMb: Math.round((parseNumber(entry.quota) / 1024) * 100) / 100, + addedDate: entry.date, + expiryDate: entry.expire, + campaignCode: entry.quotaCode ?? "", + })); + + const simHistory: SimTopUpHistory = { + account, + totalAdditions: parseNumber(response.total), + additionCount: parseNumber(response.count), + history, + }; + + return simTopUpHistorySchema.parse(simHistory); +} diff --git a/packages/integrations/freebit/src/utils/data-utils.ts b/packages/integrations/freebit/src/utils/data-utils.ts new file mode 100644 index 00000000..876a093c --- /dev/null +++ b/packages/integrations/freebit/src/utils/data-utils.ts @@ -0,0 +1,46 @@ +export function parseNumber(value: unknown, fallback = 0): number { + if (typeof value === "number") { + return value; + } + if (typeof value === "string") { + const parsed = Number.parseFloat(value); + return Number.isNaN(parsed) ? fallback : parsed; + } + return fallback; +} + +export function asString(value: unknown, fallback = ""): string { + if (typeof value === "string") return value; + if (typeof value === "number") return String(value); + return fallback; +} + +export function parseBooleanFlag(value: unknown): boolean { + if (typeof value === "boolean") return value; + if (typeof value === "number") return value === 1 || value === 10 || value === 20; + if (typeof value === "string") { + const normalized = value.trim(); + return normalized === "1" || normalized === "10" || normalized === "20" || normalized.toLowerCase() === "true"; + } + return false; +} + +export function formatIsoDate(input: unknown): string | undefined { + if (typeof input !== "string" && typeof input !== "number") { + return undefined; + } + const raw = String(input); + if (!raw) return undefined; + const isoCandidate = Number.isNaN(Date.parse(raw)) && /^\d{8}$/.test(raw) + ? `${raw.slice(0, 4)}-${raw.slice(4, 6)}-${raw.slice(6, 8)}` + : raw; + const date = new Date(isoCandidate); + if (Number.isNaN(date.getTime())) { + return undefined; + } + return date.toISOString(); +} + +export function normalizeAccount(account: string): string { + return account.replace(/[-\s()]/g, ""); +} diff --git a/packages/integrations/freebit/src/utils/index.ts b/packages/integrations/freebit/src/utils/index.ts new file mode 100644 index 00000000..8cbcf05a --- /dev/null +++ b/packages/integrations/freebit/src/utils/index.ts @@ -0,0 +1 @@ +export * from "./data-utils"; diff --git a/packages/integrations/freebit/tsconfig.json b/packages/integrations/freebit/tsconfig.json new file mode 100644 index 00000000..085c5039 --- /dev/null +++ b/packages/integrations/freebit/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "nodenext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "composite": true, + "tsBuildInfoFile": "./tsconfig.tsbuildinfo", + "paths": { + "@customer-portal/contracts": ["../../contracts/src"], + "@customer-portal/contracts/*": ["../../contracts/src/*"], + "@customer-portal/schemas": ["../../schemas/src"], + "@customer-portal/schemas/*": ["../../schemas/src/*"], + "@customer-portal/integrations-freebit": ["./src"], + "@customer-portal/integrations-freebit/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"], + "references": [ + { "path": "../../contracts" }, + { "path": "../../schemas" } + ] +} diff --git a/packages/integrations/whmcs/package.json b/packages/integrations/whmcs/package.json new file mode 100644 index 00000000..d68c8785 --- /dev/null +++ b/packages/integrations/whmcs/package.json @@ -0,0 +1,38 @@ +{ + "name": "@customer-portal/integrations-whmcs", + "version": "0.1.0", + "description": "WHMCS integration helpers (mappers, utilities).", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "private": true, + "sideEffects": false, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./mappers": { + "types": "./dist/mappers/index.d.ts", + "default": "./dist/mappers/index.js" + } + }, + "scripts": { + "build": "tsc -b", + "dev": "tsc -b -w --preserveWatchOutput", + "clean": "rm -rf dist", + "type-check": "tsc --project tsconfig.json --noEmit", + "test": "echo \"No tests for WHMCS integration package\"", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "devDependencies": { + "typescript": "^5.9.2" + }, + "dependencies": { + "@customer-portal/contracts": "workspace:*", + "@customer-portal/schemas": "workspace:*" + } +} diff --git a/packages/integrations/whmcs/src/index.ts b/packages/integrations/whmcs/src/index.ts new file mode 100644 index 00000000..6e32e52e --- /dev/null +++ b/packages/integrations/whmcs/src/index.ts @@ -0,0 +1,2 @@ +export * as WhmcsMappers from "./mappers"; +export * from "./mappers"; diff --git a/packages/integrations/whmcs/src/mappers/index.ts b/packages/integrations/whmcs/src/mappers/index.ts new file mode 100644 index 00000000..5b815c92 --- /dev/null +++ b/packages/integrations/whmcs/src/mappers/index.ts @@ -0,0 +1,4 @@ +export * from "./invoice.mapper"; +export * from "./subscription.mapper"; +export * from "./payment.mapper"; +export * from "./order.mapper"; diff --git a/packages/integrations/whmcs/src/mappers/invoice.mapper.ts b/packages/integrations/whmcs/src/mappers/invoice.mapper.ts new file mode 100644 index 00000000..40ea7b58 --- /dev/null +++ b/packages/integrations/whmcs/src/mappers/invoice.mapper.ts @@ -0,0 +1,85 @@ +import type { Invoice, InvoiceItem } from "@customer-portal/contracts/billing"; +import { invoiceSchema } from "@customer-portal/schemas/billing/invoice.schema"; +import { + whmcsInvoiceItemsSchema, + whmcsInvoiceSchema, +} from "@customer-portal/schemas/integrations/whmcs/invoice.schema"; + +import { formatDate, parseAmount } from "../utils/data-utils"; + +export interface TransformInvoiceOptions { + defaultCurrencyCode?: string; + defaultCurrencySymbol?: string; +} + +const statusMap: Record = { + draft: "Draft", + pending: "Pending", + "payment pending": "Pending", + paid: "Paid", + unpaid: "Unpaid", + cancelled: "Cancelled", + canceled: "Cancelled", + overdue: "Overdue", + refunded: "Refunded", + collections: "Collections", +}; + +function mapStatus(status: string): Invoice["status"] { + const normalized = status?.trim().toLowerCase(); + if (!normalized) { + throw new Error("Invoice status missing"); + } + + const mapped = statusMap[normalized]; + if (!mapped) { + throw new Error(`Unsupported WHMCS invoice status: ${status}`); + } + return mapped; +} + +function mapItems(rawItems: unknown): InvoiceItem[] { + if (!rawItems) return []; + + const parsed = whmcsInvoiceItemsSchema.parse(rawItems); + const itemArray = Array.isArray(parsed.item) ? parsed.item : [parsed.item]; + return itemArray.map(item => ({ + id: item.id, + description: item.description, + amount: parseAmount(item.amount), + quantity: 1, + type: item.type, + serviceId: typeof item.relid === "number" && item.relid > 0 ? item.relid : undefined, + })); +} + +export function transformWhmcsInvoice( + rawInvoice: unknown, + options: TransformInvoiceOptions = {} +): Invoice { + const whmcsInvoice = whmcsInvoiceSchema.parse(rawInvoice); + + const currency = whmcsInvoice.currencycode || options.defaultCurrencyCode || "JPY"; + const currencySymbol = + whmcsInvoice.currencyprefix || + whmcsInvoice.currencysuffix || + options.defaultCurrencySymbol; + + const invoice: Invoice = { + id: whmcsInvoice.invoiceid ?? whmcsInvoice.id ?? 0, + number: whmcsInvoice.invoicenum || `INV-${whmcsInvoice.invoiceid}`, + status: mapStatus(whmcsInvoice.status), + currency, + currencySymbol, + total: parseAmount(whmcsInvoice.total), + subtotal: parseAmount(whmcsInvoice.subtotal), + tax: parseAmount(whmcsInvoice.tax) + parseAmount(whmcsInvoice.tax2), + issuedAt: formatDate(whmcsInvoice.date || whmcsInvoice.datecreated), + dueDate: formatDate(whmcsInvoice.duedate), + paidDate: formatDate(whmcsInvoice.datepaid), + description: whmcsInvoice.notes || undefined, + items: mapItems(whmcsInvoice.items), + }; + + return invoiceSchema.parse(invoice); +} diff --git a/packages/integrations/whmcs/src/mappers/order.mapper.ts b/packages/integrations/whmcs/src/mappers/order.mapper.ts new file mode 100644 index 00000000..b129095d --- /dev/null +++ b/packages/integrations/whmcs/src/mappers/order.mapper.ts @@ -0,0 +1,85 @@ +import type { FulfillmentOrderItem } from "@customer-portal/contracts/orders"; +import type { WhmcsOrderItem } from "@bff/integrations/whmcs/services/whmcs-order.service"; +import { z } from "zod"; + +const fulfillmentOrderItemSchema = z.object({ + id: z.string(), + orderId: z.string(), + quantity: z.number().int().min(1), + product: z + .object({ + id: z.string().optional(), + sku: z.string().optional(), + itemClass: z.string().optional(), + whmcsProductId: z.string().min(1), + billingCycle: z.string().min(1), + }) + .nullable(), +}); + +export interface OrderItemMappingResult { + whmcsItems: WhmcsOrderItem[]; + summary: { + totalItems: number; + serviceItems: number; + activationItems: number; + }; +} + +function normalizeBillingCycle(cycle: string): string { + const normalized = cycle.trim().toLowerCase(); + if (normalized.includes("monthly")) return "monthly"; + if (normalized.includes("one")) return "onetime"; + return normalized; +} + +export function mapFulfillmentOrderItem( + item: FulfillmentOrderItem, + index = 0 +): WhmcsOrderItem { + const parsed = fulfillmentOrderItemSchema.parse(item); + + if (!parsed.product) { + throw new Error(`Order item ${index} missing product information`); + } + + const whmcsItem: WhmcsOrderItem = { + productId: parsed.product.whmcsProductId, + billingCycle: normalizeBillingCycle(parsed.product.billingCycle), + quantity: parsed.quantity, + }; + + return whmcsItem; +} + +export function mapFulfillmentOrderItems( + items: FulfillmentOrderItem[] +): OrderItemMappingResult { + if (!Array.isArray(items) || items.length === 0) { + throw new Error("No order items provided for WHMCS mapping"); + } + + const whmcsItems: WhmcsOrderItem[] = []; + let serviceItems = 0; + let activationItems = 0; + + items.forEach((item, index) => { + const mapped = mapFulfillmentOrderItem(item, index); + whmcsItems.push(mapped); + if (mapped.billingCycle === "monthly") { + serviceItems++; + } else if (mapped.billingCycle === "onetime") { + activationItems++; + } + }); + + return { + whmcsItems, + summary: { + totalItems: whmcsItems.length, + serviceItems, + activationItems, + }, + }; +} +*** End Patch diff --git a/packages/integrations/whmcs/src/mappers/payment.mapper.ts b/packages/integrations/whmcs/src/mappers/payment.mapper.ts new file mode 100644 index 00000000..8630ab6f --- /dev/null +++ b/packages/integrations/whmcs/src/mappers/payment.mapper.ts @@ -0,0 +1,94 @@ +import type { PaymentGateway, PaymentMethod } from "@customer-portal/contracts/payments"; +import { + paymentGatewaySchema, + paymentMethodSchema, +} from "@customer-portal/schemas/payments/payment.schema"; +import { + whmcsPaymentGatewaySchema, + whmcsPaymentMethodSchema, +} from "@customer-portal/schemas/integrations/whmcs/payment.schema"; + +const paymentMethodTypeMap: Record = { + creditcard: "CreditCard", + bankaccount: "BankAccount", + remotecard: "RemoteCreditCard", + remotebankaccount: "RemoteBankAccount", + manual: "Manual", + remoteccreditcard: "RemoteCreditCard", +}; + +function mapPaymentMethodType(type: string): PaymentMethod["type"] { + const normalized = type.trim().toLowerCase(); + return paymentMethodTypeMap[normalized] ?? "Manual"; +} + +const paymentGatewayTypeMap: Record = { + merchant: "merchant", + thirdparty: "thirdparty", + "third-party": "thirdparty", + tokenization: "tokenization", + tokenised: "tokenization", + manual: "manual", +}; + +function mapPaymentGatewayType(type?: string | null): PaymentGateway["type"] { + if (!type) { + return "manual"; + } + const normalized = type.trim().toLowerCase(); + return paymentGatewayTypeMap[normalized] ?? "manual"; +} + +export function transformWhmcsPaymentMethod(raw: unknown): PaymentMethod { + const method = whmcsPaymentMethodSchema.parse(raw); + + const paymentMethod: PaymentMethod = { + id: method.id, + type: mapPaymentMethodType(method.type), + description: method.description || method.type || "Manual payment method", + gatewayName: method.gateway_name || undefined, + contactType: method.contact_type || undefined, + contactId: method.contact_id ?? undefined, + cardLastFour: method.card_last_four || undefined, + expiryDate: method.expiry_date || undefined, + startDate: method.start_date || undefined, + issueNumber: method.issue_number || undefined, + cardType: method.card_type || undefined, + remoteToken: method.remote_token || undefined, + lastUpdated: method.last_updated || undefined, + bankName: method.bank_name || undefined, + isDefault: + typeof method.is_default === "boolean" + ? method.is_default + : method.is_default === 1 + ? true + : undefined, + }; + + return paymentMethodSchema.parse(paymentMethod); +} + +export function transformWhmcsPaymentGateway(raw: unknown): PaymentGateway { + const gateway = whmcsPaymentGatewaySchema.parse(raw); + + const paymentGateway: PaymentGateway = { + name: gateway.name, + displayName: gateway.display_name || gateway.name, + type: mapPaymentGatewayType(gateway.type), + isActive: + typeof gateway.active === "boolean" + ? gateway.active + : gateway.active === 1 + ? true + : gateway.active === "1" + ? true + : gateway.active === 0 + ? false + : gateway.active === "0" + ? false + : true, + configuration: undefined, + }; + + return paymentGatewaySchema.parse(paymentGateway); +} diff --git a/packages/integrations/whmcs/src/mappers/subscription.mapper.ts b/packages/integrations/whmcs/src/mappers/subscription.mapper.ts new file mode 100644 index 00000000..70d8d7ba --- /dev/null +++ b/packages/integrations/whmcs/src/mappers/subscription.mapper.ts @@ -0,0 +1,129 @@ +import type { z } from "zod"; + +import type { Subscription } from "@customer-portal/contracts/subscriptions"; +import { + subscriptionSchema, +} from "@customer-portal/schemas/subscriptions/subscription.schema"; +import { + whmcsCustomFieldsContainerSchema, + whmcsProductSchema, +} from "@customer-portal/schemas/integrations/whmcs/product.schema"; + +import { formatDate, parseAmount } from "../utils/data-utils"; + +export interface TransformSubscriptionOptions { + defaultCurrencyCode?: string; + defaultCurrencySymbol?: string; +} + +const statusMap: Record = { + active: "Active", + inactive: "Inactive", + pending: "Pending", + cancelled: "Cancelled", + canceled: "Cancelled", + terminated: "Terminated", + completed: "Completed", + suspended: "Suspended", + fraud: "Cancelled", +}; + +const cycleMap: Record = { + monthly: "Monthly", + annually: "Annually", + annual: "Annually", + yearly: "Annually", + quarterly: "Quarterly", + "semi annually": "Semi-Annually", + semiannually: "Semi-Annually", + "semi-annually": "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): Subscription["status"] { + if (!status) { + return "Cancelled"; + } + const mapped = statusMap[status.trim().toLowerCase()]; + return mapped ?? "Cancelled"; +} + +function mapCycle(cycle?: string | null): Subscription["cycle"] { + if (!cycle) { + return "One-time"; + } + const normalized = cycle.trim().toLowerCase().replace(/[_\s-]+/g, " "); + return cycleMap[normalized] ?? "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; +} + +function resolveAmount(product: z.infer): number { + const recurring = parseAmount(product.recurringamount); + if (recurring > 0) { + return recurring; + } + return parseAmount(product.firstpaymentamount); +} + +export function transformWhmcsSubscription( + rawProduct: unknown, + options: TransformSubscriptionOptions = {} +): Subscription { + const product = whmcsProductSchema.parse(rawProduct); + + const id = Number(product.id); + if (!Number.isFinite(id)) { + throw new Error("WHMCS product id is not numeric"); + } + + const currency = options.defaultCurrencyCode ?? "JPY"; + const currencySymbol = options.defaultCurrencySymbol; + + const subscription: Subscription = { + id, + serviceId: id, + productName: product.name || product.translated_name || String(product.id), + domain: product.domain || undefined, + status: mapStatus(product.status), + cycle: mapCycle(product.billingcycle), + amount: resolveAmount(product), + currency, + currencySymbol, + nextDue: formatDate(product.nextduedate), + registrationDate: formatDate(product.regdate) || new Date().toISOString(), + customFields: extractCustomFields(product.customfields), + notes: product.notes || undefined, + orderNumber: product.ordernumber || undefined, + groupName: product.groupname || product.translated_groupname || undefined, + paymentMethod: product.paymentmethodname || product.paymentmethod || undefined, + serverName: product.servername || product.serverhostname || undefined, + }; + + return subscriptionSchema.parse(subscription); +} diff --git a/packages/integrations/whmcs/src/utils/data-utils.ts b/packages/integrations/whmcs/src/utils/data-utils.ts new file mode 100644 index 00000000..16528777 --- /dev/null +++ b/packages/integrations/whmcs/src/utils/data-utils.ts @@ -0,0 +1,28 @@ +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; +} + +export function formatDate(input: string | undefined): string | undefined { + if (!input) return undefined; + const date = new Date(input); + return Number.isNaN(date.getTime()) ? undefined : date.toISOString(); +} + +export function toErrorMessage(error: unknown): string { + if (error instanceof Error) { + return error.message; + } + if (typeof error === "string") { + return error; + } + try { + return JSON.stringify(error); + } catch { + return String(error); + } +} diff --git a/packages/integrations/whmcs/tsconfig.json b/packages/integrations/whmcs/tsconfig.json new file mode 100644 index 00000000..121d1917 --- /dev/null +++ b/packages/integrations/whmcs/tsconfig.json @@ -0,0 +1,28 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "nodenext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "composite": true, + "tsBuildInfoFile": "./tsconfig.tsbuildinfo", + "paths": { + "@customer-portal/contracts": ["../../contracts/src"], + "@customer-portal/contracts/*": ["../../contracts/src/*"], + "@customer-portal/schemas": ["../../schemas/src"], + "@customer-portal/schemas/*": ["../../schemas/src/*"], + "@customer-portal/integrations-whmcs": ["./src"], + "@customer-portal/integrations-whmcs/*": ["./src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"], + "references": [ + { "path": "../../contracts" }, + { "path": "../../schemas" } + ] +} diff --git a/packages/schemas/package.json b/packages/schemas/package.json new file mode 100644 index 00000000..1e97ba7e --- /dev/null +++ b/packages/schemas/package.json @@ -0,0 +1,53 @@ +{ + "name": "@customer-portal/schemas", + "version": "0.1.0", + "description": "Runtime validation schemas for customer portal domain and integrations.", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "private": true, + "sideEffects": false, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./billing": { + "types": "./dist/billing/index.d.ts", + "default": "./dist/billing/index.js" + }, + "./subscriptions": { + "types": "./dist/subscriptions/index.d.ts", + "default": "./dist/subscriptions/index.js" + }, + "./payments": { + "types": "./dist/payments/index.d.ts", + "default": "./dist/payments/index.js" + }, + "./sim": { + "types": "./dist/sim/index.d.ts", + "default": "./dist/sim/index.js" + }, + "./integrations": { + "types": "./dist/integrations/index.d.ts", + "default": "./dist/integrations/index.js" + } + }, + "scripts": { + "build": "tsc -b", + "dev": "tsc -b -w --preserveWatchOutput", + "clean": "rm -rf dist", + "type-check": "tsc --project tsconfig.json --noEmit", + "test": "echo \"No tests for schemas package\"", + "lint": "eslint .", + "lint:fix": "eslint . --fix" + }, + "devDependencies": { + "typescript": "^5.9.2" + }, + "dependencies": { + "zod": "^4.1.9" + } +} diff --git a/packages/schemas/src/billing/index.ts b/packages/schemas/src/billing/index.ts new file mode 100644 index 00000000..63f1daa6 --- /dev/null +++ b/packages/schemas/src/billing/index.ts @@ -0,0 +1 @@ +export * from "./invoice.schema"; diff --git a/packages/schemas/src/billing/invoice.schema.ts b/packages/schemas/src/billing/invoice.schema.ts new file mode 100644 index 00000000..492dc870 --- /dev/null +++ b/packages/schemas/src/billing/invoice.schema.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; + +import type { Invoice, InvoiceItem, InvoiceList, InvoiceStatus } from "@customer-portal/contracts/billing"; + +const invoiceStatusValues: readonly InvoiceStatus[] = [ + "Draft", + "Pending", + "Paid", + "Unpaid", + "Overdue", + "Cancelled", + "Refunded", + "Collections", +] as const; + +export const invoiceStatusSchema = z.enum(invoiceStatusValues); + +export const invoiceItemSchema = z.object({ + id: z.number().int().positive("Invoice item id must be positive"), + description: z.string().min(1, "Description is required"), + amount: z.number(), + quantity: z.number().int().positive("Quantity must be positive").optional(), + type: z.string().min(1, "Item type is required"), + serviceId: z.number().int().positive().optional(), +}) satisfies z.ZodType; + +export const invoiceSchema = z.object({ + id: z.number().int().positive("Invoice id must be positive"), + number: z.string().min(1, "Invoice number is required"), + status: invoiceStatusSchema, + currency: z.string().min(1, "Currency is required"), + currencySymbol: z.string().min(1, "Currency symbol is required").optional(), + total: z.number(), + subtotal: z.number(), + tax: z.number(), + issuedAt: z.string().optional(), + dueDate: z.string().optional(), + paidDate: z.string().optional(), + pdfUrl: z.string().optional(), + paymentUrl: z.string().optional(), + description: z.string().optional(), + items: z.array(invoiceItemSchema).optional(), + daysOverdue: z.number().int().nonnegative().optional(), +}) satisfies z.ZodType; + +export const invoicePaginationSchema = z.object({ + page: z.number().int().nonnegative(), + totalPages: z.number().int().nonnegative(), + totalItems: z.number().int().nonnegative(), + nextCursor: z.string().optional(), +}); + +export const invoiceListSchema = z.object({ + invoices: z.array(invoiceSchema), + pagination: invoicePaginationSchema, +}) satisfies z.ZodType; + +export type InvoiceSchema = typeof invoiceSchema; +export type InvoiceItemSchema = typeof invoiceItemSchema; +export type InvoiceListSchema = typeof invoiceListSchema; diff --git a/packages/schemas/src/index.ts b/packages/schemas/src/index.ts new file mode 100644 index 00000000..a1e55fc2 --- /dev/null +++ b/packages/schemas/src/index.ts @@ -0,0 +1,11 @@ +export * as BillingSchemas from "./billing"; +export * as SubscriptionSchemas from "./subscriptions"; +export * as PaymentSchemas from "./payments"; +export * as SimSchemas from "./sim"; +export * as IntegrationSchemas from "./integrations"; + +export * from "./billing"; +export * from "./subscriptions"; +export * from "./payments"; +export * from "./sim"; +export * from "./integrations"; diff --git a/packages/schemas/src/integrations/freebit/account.schema.ts b/packages/schemas/src/integrations/freebit/account.schema.ts new file mode 100644 index 00000000..c97f7b6a --- /dev/null +++ b/packages/schemas/src/integrations/freebit/account.schema.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +export const freebitStatusSchema = z.object({ + message: z.string().optional(), + statusCode: z.union([z.string(), z.number()]).optional(), +}); + +export const freebitAccountDetailSchema = z.object({ + kind: z.string().optional(), + account: z.union([z.string(), z.number()]).nullable().optional(), + state: z.string().optional(), + status: z.string().optional(), + planCode: z.union([z.string(), z.number()]).nullable().optional(), + planName: z.union([z.string(), z.number()]).nullable().optional(), + simSize: z.string().nullable().optional(), + eid: z.union([z.string(), z.number()]).nullable().optional(), + iccid: z.union([z.string(), z.number()]).nullable().optional(), + imsi: z.union([z.string(), z.number()]).nullable().optional(), + msisdn: z.union([z.string(), z.number()]).nullable().optional(), + remainingQuotaMb: z.union([z.string(), z.number()]).nullable().optional(), + remainingQuotaKb: z.union([z.string(), z.number()]).nullable().optional(), + voicemail: z.union([z.string(), z.number()]).nullable().optional(), + voiceMail: z.union([z.string(), z.number()]).nullable().optional(), + callwaiting: z.union([z.string(), z.number()]).nullable().optional(), + callWaiting: z.union([z.string(), z.number()]).nullable().optional(), + worldwing: z.union([z.string(), z.number()]).nullable().optional(), + worldWing: z.union([z.string(), z.number()]).nullable().optional(), + async: z + .object({ + func: z.string().optional(), + date: z.union([z.string(), z.number()]).optional(), + }) + .nullable() + .optional(), + simType: z.string().nullable().optional(), + contractLine: z.string().nullable().optional(), + startDate: z.union([z.string(), z.number()]).nullable().optional(), +}); + +export const freebitAccountDetailsResponseSchema = z.object({ + resultCode: z.string(), + status: freebitStatusSchema, + masterAccount: z.union([z.string(), z.number()]).nullable().optional(), + responseDatas: z.array(freebitAccountDetailSchema), +}); + +export type FreebitAccountDetailsResponseSchema = typeof freebitAccountDetailsResponseSchema; diff --git a/packages/schemas/src/integrations/freebit/index.ts b/packages/schemas/src/integrations/freebit/index.ts new file mode 100644 index 00000000..1624adb0 --- /dev/null +++ b/packages/schemas/src/integrations/freebit/index.ts @@ -0,0 +1,4 @@ +export * from "./account.schema"; +export * from "./traffic.schema"; +export * from "./quota.schema"; +export * as FreebitRequestSchemas from "./requests"; diff --git a/packages/schemas/src/integrations/freebit/quota.schema.ts b/packages/schemas/src/integrations/freebit/quota.schema.ts new file mode 100644 index 00000000..fedbbb7f --- /dev/null +++ b/packages/schemas/src/integrations/freebit/quota.schema.ts @@ -0,0 +1,21 @@ +import { z } from "zod"; + +export const freebitQuotaHistoryItemSchema = z.object({ + quota: z.string(), + date: z.string(), + expire: z.string(), + quotaCode: z.string().optional(), +}); + +export const freebitQuotaHistoryResponseSchema = z.object({ + resultCode: z.string(), + status: z.object({ + message: z.string().optional(), + statusCode: z.union([z.string(), z.number()]).optional(), + }), + total: z.union([z.string(), z.number()]), + count: z.union([z.string(), z.number()]), + quotaHistory: z.array(freebitQuotaHistoryItemSchema), +}); + +export type FreebitQuotaHistoryResponseSchema = typeof freebitQuotaHistoryResponseSchema; diff --git a/packages/schemas/src/integrations/freebit/requests/account.schema.ts b/packages/schemas/src/integrations/freebit/requests/account.schema.ts new file mode 100644 index 00000000..f0f3eb2c --- /dev/null +++ b/packages/schemas/src/integrations/freebit/requests/account.schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const freebitAccountDetailsRequestSchema = z.object({ + version: z.string().optional(), + requestDatas: z + .array( + z.object({ + kind: z.enum(["MASTER", "MVNO"]), + account: z.union([z.string(), z.number()]).optional(), + }) + ) + .min(1, "At least one request data entry is required"), +}); + +export const freebitTrafficInfoRequestSchema = z.object({ + account: z.string().min(1, "Account is required"), +}); diff --git a/packages/schemas/src/integrations/freebit/requests/index.ts b/packages/schemas/src/integrations/freebit/requests/index.ts new file mode 100644 index 00000000..14a752cd --- /dev/null +++ b/packages/schemas/src/integrations/freebit/requests/index.ts @@ -0,0 +1,3 @@ +export * from "./topup.schema"; +export * from "./account.schema"; +export * from "./plan-change.schema"; diff --git a/packages/schemas/src/integrations/freebit/requests/plan-change.schema.ts b/packages/schemas/src/integrations/freebit/requests/plan-change.schema.ts new file mode 100644 index 00000000..84ceda04 --- /dev/null +++ b/packages/schemas/src/integrations/freebit/requests/plan-change.schema.ts @@ -0,0 +1,28 @@ +import { z } from "zod"; + +export const freebitPlanChangeRequestSchema = z.object({ + account: z.string().min(1, "Account is required"), + newPlanCode: z.string().min(1, "New plan code is required"), + assignGlobalIp: z.boolean().optional(), + scheduledAt: z.string().optional(), +}); + +export const freebitAddSpecRequestSchema = z.object({ + account: z.string().min(1, "Account is required"), + specCode: z.string().min(1, "Spec code is required"), + enabled: z.boolean().optional(), + networkType: z.enum(["4G", "5G"]).optional(), +}); + +export const freebitCancelPlanRequestSchema = z.object({ + account: z.string().min(1, "Account is required"), + runDate: z.string().optional(), +}); + +export const freebitEsimReissueRequestSchema = z.object({ + account: z.string().min(1, "Account is required"), + newEid: z.string().min(1, "New EID is required"), + oldEid: z.string().optional(), + planCode: z.string().optional(), + oldProductNumber: z.string().optional(), +}); diff --git a/packages/schemas/src/integrations/freebit/requests/topup.schema.ts b/packages/schemas/src/integrations/freebit/requests/topup.schema.ts new file mode 100644 index 00000000..4b6ff455 --- /dev/null +++ b/packages/schemas/src/integrations/freebit/requests/topup.schema.ts @@ -0,0 +1,13 @@ +import { z } from "zod"; + +export const freebitTopUpOptionsSchema = z.object({ + campaignCode: z.string().optional(), + expiryDate: z.string().optional(), + scheduledAt: z.string().optional(), +}); + +export const freebitTopUpRequestPayloadSchema = z.object({ + account: z.string().min(1, "Account is required"), + quotaMb: z.number().positive("Quota must be positive"), + options: freebitTopUpOptionsSchema.optional(), +}); diff --git a/packages/schemas/src/integrations/freebit/traffic.schema.ts b/packages/schemas/src/integrations/freebit/traffic.schema.ts new file mode 100644 index 00000000..7bd639a1 --- /dev/null +++ b/packages/schemas/src/integrations/freebit/traffic.schema.ts @@ -0,0 +1,17 @@ +import { z } from "zod"; + +export const freebitTrafficInfoSchema = z.object({ + resultCode: z.string(), + status: z.object({ + message: z.string().optional(), + statusCode: z.union([z.string(), z.number()]).optional(), + }), + account: z.string(), + traffic: z.object({ + today: z.string(), + inRecentDays: z.string(), + blackList: z.string(), + }), +}); + +export type FreebitTrafficInfoSchema = typeof freebitTrafficInfoSchema; diff --git a/packages/schemas/src/integrations/index.ts b/packages/schemas/src/integrations/index.ts new file mode 100644 index 00000000..1782522f --- /dev/null +++ b/packages/schemas/src/integrations/index.ts @@ -0,0 +1,4 @@ +export * as WhmcsSchemas from "./whmcs"; +export * as FreebitSchemas from "./freebit"; +export * from "./whmcs"; +export * from "./freebit"; diff --git a/packages/schemas/src/integrations/whmcs/index.ts b/packages/schemas/src/integrations/whmcs/index.ts new file mode 100644 index 00000000..a52d66f4 --- /dev/null +++ b/packages/schemas/src/integrations/whmcs/index.ts @@ -0,0 +1,3 @@ +export * from "./invoice.schema"; +export * from "./product.schema"; +export * from "./payment.schema"; diff --git a/packages/schemas/src/integrations/whmcs/invoice.schema.ts b/packages/schemas/src/integrations/whmcs/invoice.schema.ts new file mode 100644 index 00000000..328c88b7 --- /dev/null +++ b/packages/schemas/src/integrations/whmcs/invoice.schema.ts @@ -0,0 +1,47 @@ +import { z } from "zod"; + +export const whmcsInvoiceItemSchema = z.object({ + id: z.number(), + type: z.string(), + relid: z.number(), + description: z.string(), + amount: z.union([z.string(), z.number()]), + taxed: z.number().optional(), +}); + +export const whmcsInvoiceItemsSchema = z.object({ + item: z.union([whmcsInvoiceItemSchema, z.array(whmcsInvoiceItemSchema)]), +}); + +export const whmcsInvoiceSchema = z.object({ + invoiceid: z.number(), + invoicenum: z.string(), + userid: z.number(), + date: z.string(), + duedate: z.string(), + subtotal: z.string(), + credit: z.string(), + tax: z.string(), + tax2: z.string(), + total: z.string(), + balance: z.string().optional(), + status: z.string(), + paymentmethod: z.string(), + notes: z.string().optional(), + ccgateway: z.boolean().optional(), + items: whmcsInvoiceItemsSchema.optional(), + transactions: z.unknown().optional(), + id: z.number().optional(), + clientid: z.number().optional(), + datecreated: z.string().optional(), + paymentmethodname: z.string().optional(), + currencycode: z.string().optional(), + currencyprefix: z.string().optional(), + currencysuffix: z.string().optional(), + lastcaptureattempt: z.string().optional(), + taxrate: z.string().optional(), + taxrate2: z.string().optional(), + datepaid: z.string().optional(), +}); + +export type WhmcsInvoiceSchema = typeof whmcsInvoiceSchema; diff --git a/packages/schemas/src/integrations/whmcs/payment.schema.ts b/packages/schemas/src/integrations/whmcs/payment.schema.ts new file mode 100644 index 00000000..cfa0ff28 --- /dev/null +++ b/packages/schemas/src/integrations/whmcs/payment.schema.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; + +export const whmcsPaymentMethodSchema = z.object({ + id: z.number(), + type: z.string(), + description: z.string().optional(), + gateway_name: z.string().optional(), + contact_type: z.string().optional(), + contact_id: z.number().optional(), + card_last_four: z.string().optional(), + expiry_date: z.string().optional(), + start_date: z.string().optional(), + issue_number: z.string().optional(), + card_type: z.string().optional(), + remote_token: z.string().optional(), + last_updated: z.string().optional(), + bank_name: z.string().optional(), + is_default: z.boolean().optional(), +}); + +export const whmcsPaymentGatewaySchema = z.object({ + name: z.string(), + display_name: z.string().optional(), + type: z.string().optional(), + active: z.boolean().optional(), +}); + +export type WhmcsPaymentMethodSchema = typeof whmcsPaymentMethodSchema; +export type WhmcsPaymentGatewaySchema = typeof whmcsPaymentGatewaySchema; diff --git a/packages/schemas/src/integrations/whmcs/product.schema.ts b/packages/schemas/src/integrations/whmcs/product.schema.ts new file mode 100644 index 00000000..c073f149 --- /dev/null +++ b/packages/schemas/src/integrations/whmcs/product.schema.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; + +export const whmcsCustomFieldSchema = z.object({ + id: z.number(), + value: z.string().optional(), + name: z.string().optional(), + type: z.string().optional(), +}); + +export const whmcsCustomFieldsContainerSchema = z.object({ + customfield: z.union([whmcsCustomFieldSchema, z.array(whmcsCustomFieldSchema)]), +}); + +export const whmcsConfigOptionSchema = z.object({ + id: z.union([z.number(), z.string()]).optional(), + option: z.string().optional(), + type: z.string().optional(), + value: z.string().optional(), +}); + +export const whmcsConfigOptionsSchema = z.object({ + configoption: z.union([whmcsConfigOptionSchema, z.array(whmcsConfigOptionSchema)]).optional(), +}); + +export const whmcsProductSchema = z.object({ + id: z.union([z.number(), z.string()]), + qty: z.string().optional(), + clientid: z.union([z.number(), z.string()]).optional(), + orderid: z.union([z.number(), z.string()]).optional(), + ordernumber: z.string().optional(), + pid: z.union([z.number(), z.string()]).optional(), + regdate: z.string().optional(), + name: z.string().optional(), + translated_name: z.string().optional(), + groupname: z.string().optional(), + translated_groupname: z.string().optional(), + domain: z.string().optional(), + dedicatedip: z.string().optional(), + serverid: z.union([z.number(), z.string()]).optional(), + servername: z.string().optional(), + serverip: z.string().optional(), + serverhostname: z.string().optional(), + suspensionreason: z.string().optional(), + firstpaymentamount: z.string().optional(), + recurringamount: z.string().optional(), + paymentmethod: z.string().optional(), + paymentmethodname: z.string().optional(), + billingcycle: z.string().optional(), + nextduedate: z.string().optional(), + status: z.string().optional(), + username: z.string().optional(), + password: z.string().optional(), + subscriptionid: z.string().optional(), + promoid: z.union([z.number(), z.string()]).optional(), + overideautosuspend: z.string().optional(), + overidesuspenduntil: z.string().optional(), + ns1: z.string().optional(), + ns2: z.string().optional(), + assignedips: z.string().optional(), + notes: z.string().optional(), + diskusage: z.string().optional(), + disklimit: z.string().optional(), + bwusage: z.string().optional(), + bwlimit: z.string().optional(), + lastupdate: z.string().optional(), + customfields: whmcsCustomFieldsContainerSchema.optional(), + configoptions: whmcsConfigOptionsSchema.optional(), +}); + +export type WhmcsProductSchema = typeof whmcsProductSchema; diff --git a/packages/schemas/src/payments/index.ts b/packages/schemas/src/payments/index.ts new file mode 100644 index 00000000..614f36f6 --- /dev/null +++ b/packages/schemas/src/payments/index.ts @@ -0,0 +1 @@ +export * from "./payment.schema"; diff --git a/packages/schemas/src/payments/payment.schema.ts b/packages/schemas/src/payments/payment.schema.ts new file mode 100644 index 00000000..6552b64d --- /dev/null +++ b/packages/schemas/src/payments/payment.schema.ts @@ -0,0 +1,67 @@ +import { z } from "zod"; + +import type { + PaymentGateway, + PaymentGatewayList, + PaymentGatewayType, + PaymentMethod, + PaymentMethodList, + PaymentMethodType, +} from "@customer-portal/contracts/payments"; + +const paymentMethodTypeValues: readonly PaymentMethodType[] = [ + "CreditCard", + "BankAccount", + "RemoteCreditCard", + "RemoteBankAccount", + "Manual", +] as const; + +const paymentGatewayTypeValues: readonly PaymentGatewayType[] = [ + "merchant", + "thirdparty", + "tokenization", + "manual", +] as const; + +export const paymentMethodTypeSchema = z.enum(paymentMethodTypeValues); +export const paymentGatewayTypeSchema = z.enum(paymentGatewayTypeValues); + +export const paymentMethodSchema = z.object({ + id: z.number().int().nonnegative(), + type: paymentMethodTypeSchema, + description: z.string().min(1, "Description is required"), + gatewayName: z.string().optional(), + contactType: z.string().optional(), + contactId: z.number().int().nonnegative().optional(), + cardLastFour: z.string().optional(), + expiryDate: z.string().optional(), + startDate: z.string().optional(), + issueNumber: z.string().optional(), + cardType: z.string().optional(), + remoteToken: z.string().optional(), + lastUpdated: z.string().optional(), + bankName: z.string().optional(), + isDefault: z.boolean().optional(), +}) satisfies z.ZodType; + +export const paymentMethodListSchema = z.object({ + paymentMethods: z.array(paymentMethodSchema), + totalCount: z.number().int().nonnegative(), +}) satisfies z.ZodType; + +export const paymentGatewaySchema = z.object({ + name: z.string().min(1, "Gateway name is required"), + displayName: z.string().min(1, "Display name is required"), + type: paymentGatewayTypeSchema, + isActive: z.boolean(), + configuration: z.record(z.string(), z.unknown()).optional(), +}) satisfies z.ZodType; + +export const paymentGatewayListSchema = z.object({ + gateways: z.array(paymentGatewaySchema), + totalCount: z.number().int().nonnegative(), +}) satisfies z.ZodType; + +export type PaymentMethodSchema = typeof paymentMethodSchema; +export type PaymentGatewaySchema = typeof paymentGatewaySchema; diff --git a/packages/schemas/src/sim/index.ts b/packages/schemas/src/sim/index.ts new file mode 100644 index 00000000..74914976 --- /dev/null +++ b/packages/schemas/src/sim/index.ts @@ -0,0 +1 @@ +export * from "./sim.schema"; diff --git a/packages/schemas/src/sim/sim.schema.ts b/packages/schemas/src/sim/sim.schema.ts new file mode 100644 index 00000000..c8e3e440 --- /dev/null +++ b/packages/schemas/src/sim/sim.schema.ts @@ -0,0 +1,70 @@ +import { z } from "zod"; + +import type { + SimDetails, + SimTopUpHistory, + SimTopUpHistoryEntry, + SimUsage, +} from "@customer-portal/contracts/sim"; + +const simStatusValues = ["active", "suspended", "cancelled", "pending"] as const; +const simTypeValues = ["standard", "nano", "micro", "esim"] as const; + +export const simStatusSchema = z.enum(simStatusValues); +export const simTypeSchema = z.enum(simTypeValues); + +export const simDetailsSchema = z.object({ + account: z.string().min(1, "Account is required"), + status: simStatusSchema, + planCode: z.string().min(1, "Plan code is required"), + planName: z.string().min(1, "Plan name is required"), + simType: simTypeSchema, + iccid: z.string().min(1, "ICCID is required"), + eid: z.string().optional().default(""), + msisdn: z.string().min(1, "MSISDN is required"), + imsi: z.string().min(1, "IMSI is required"), + remainingQuotaMb: z.number().nonnegative(), + remainingQuotaKb: z.number().nonnegative(), + voiceMailEnabled: z.boolean(), + callWaitingEnabled: z.boolean(), + internationalRoamingEnabled: z.boolean(), + networkType: z.string().min(1, "Network type is required"), + activatedAt: z.string().optional(), + expiresAt: z.string().optional(), +}) satisfies z.ZodType; + +export const recentDayUsageSchema = z.object({ + date: z.string().min(1, "Usage date required"), + usageKb: z.number().nonnegative(), + usageMb: z.number().nonnegative(), +}); + +export const simUsageSchema = z.object({ + account: z.string().min(1, "Account is required"), + todayUsageMb: z.number().nonnegative(), + todayUsageKb: z.number().nonnegative(), + monthlyUsageMb: z.number().nonnegative().optional(), + monthlyUsageKb: z.number().nonnegative().optional(), + recentDaysUsage: z.array(recentDayUsageSchema), + isBlacklisted: z.boolean(), + lastUpdated: z.string().optional(), +}) satisfies z.ZodType; + +export const simTopUpHistoryEntrySchema = z.object({ + quotaKb: z.number().nonnegative(), + quotaMb: z.number().nonnegative(), + addedDate: z.string().min(1, "Added date is required"), + expiryDate: z.string().min(1, "Expiry date is required"), + campaignCode: z.string().min(1, "Campaign code is required"), +}) satisfies z.ZodType; + +export const simTopUpHistorySchema = z.object({ + account: z.string().min(1, "Account is required"), + totalAdditions: z.number().nonnegative(), + additionCount: z.number().nonnegative(), + history: z.array(simTopUpHistoryEntrySchema), +}) satisfies z.ZodType; + +export type SimDetailsSchema = typeof simDetailsSchema; +export type SimUsageSchema = typeof simUsageSchema; +export type SimTopUpHistorySchema = typeof simTopUpHistorySchema; diff --git a/packages/schemas/src/subscriptions/index.ts b/packages/schemas/src/subscriptions/index.ts new file mode 100644 index 00000000..fbc84af6 --- /dev/null +++ b/packages/schemas/src/subscriptions/index.ts @@ -0,0 +1 @@ +export * from "./subscription.schema"; diff --git a/packages/schemas/src/subscriptions/subscription.schema.ts b/packages/schemas/src/subscriptions/subscription.schema.ts new file mode 100644 index 00000000..d19bc453 --- /dev/null +++ b/packages/schemas/src/subscriptions/subscription.schema.ts @@ -0,0 +1,60 @@ +import { z } from "zod"; + +import type { + Subscription, + SubscriptionCycle, + SubscriptionList, + SubscriptionStatus, +} from "@customer-portal/contracts/subscriptions"; + +const subscriptionStatusValues: readonly SubscriptionStatus[] = [ + "Active", + "Inactive", + "Pending", + "Cancelled", + "Suspended", + "Terminated", + "Completed", +] as const; + +const subscriptionCycleValues: readonly SubscriptionCycle[] = [ + "Monthly", + "Quarterly", + "Semi-Annually", + "Annually", + "Biennially", + "Triennially", + "One-time", + "Free", +] as const; + +export const subscriptionStatusSchema = z.enum(subscriptionStatusValues); +export const subscriptionCycleSchema = z.enum(subscriptionCycleValues); + +export const subscriptionSchema = z.object({ + id: z.number().int().positive("Subscription id must be positive"), + serviceId: z.number().int().positive("Service id must be positive"), + productName: z.string().min(1, "Product name is required"), + domain: z.string().optional(), + cycle: subscriptionCycleSchema, + status: subscriptionStatusSchema, + nextDue: z.string().optional(), + amount: z.number(), + currency: z.string().min(1, "Currency is required"), + currencySymbol: z.string().optional(), + registrationDate: z.string().min(1, "Registration date is required"), + notes: z.string().optional(), + customFields: z.record(z.string(), z.string()).optional(), + orderNumber: z.string().optional(), + groupName: z.string().optional(), + paymentMethod: z.string().optional(), + serverName: z.string().optional(), +}) satisfies z.ZodType; + +export const subscriptionListSchema = z.object({ + subscriptions: z.array(subscriptionSchema), + totalCount: z.number().int().nonnegative(), +}) satisfies z.ZodType; + +export type SubscriptionSchema = typeof subscriptionSchema; +export type SubscriptionListSchema = typeof subscriptionListSchema; diff --git a/packages/schemas/tsconfig.json b/packages/schemas/tsconfig.json new file mode 100644 index 00000000..d508f694 --- /dev/null +++ b/packages/schemas/tsconfig.json @@ -0,0 +1,20 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "nodenext", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "declaration": true, + "declarationMap": true, + "composite": true, + "tsBuildInfoFile": "./tsconfig.tsbuildinfo", + "paths": { + "@customer-portal/contracts": ["../contracts/src"], + "@customer-portal/contracts/*": ["../contracts/src/*"] + } + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "**/*.test.ts", "**/*.spec.ts"] +} diff --git a/tsconfig.json b/tsconfig.json index 5e8630d2..a1b3a5fe 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,6 +2,10 @@ "extends": "./tsconfig.base.json", "files": [], "references": [ + { "path": "./packages/contracts" }, + { "path": "./packages/schemas" }, + { "path": "./packages/integrations/whmcs" }, + { "path": "./packages/integrations/freebit" }, { "path": "./packages/domain" }, { "path": "./packages/logging" }, { "path": "./packages/validation" }