From 93e28fc20d2def44115310c3f671acc340f3d560 Mon Sep 17 00:00:00 2001 From: barsa Date: Fri, 3 Oct 2025 13:19:26 +0900 Subject: [PATCH] Update TypeScript configurations and refactor SIM management services to utilize shared contracts. Add new package references for contracts and schemas, enhancing type safety across the application. Streamline SIM-related interfaces and services by importing types from the contracts package, improving maintainability and consistency in data handling. Remove deprecated validation logic and integrate new transformation methods for invoice and subscription entities. --- .../freebit/interfaces/freebit.types.ts | 44 +---- .../services/freebit-mapper.service.ts | 99 ++---------- .../services/freebit-operations.service.ts | 153 +++++++++++------- .../services/invoice-transformer.service.ts | 113 +------------ .../services/payment-transformer.service.ts | 57 ++----- .../subscription-transformer.service.ts | 128 +-------------- .../whmcs-transformer-orchestrator.service.ts | 4 +- .../validators/transformation-validator.ts | 129 --------------- .../src/integrations/whmcs/whmcs.module.ts | 2 - .../services/order-whmcs-mapper.service.ts | 74 +++------ .../subscriptions/sim-management.service.ts | 6 +- .../services/sim-details.service.ts | 2 +- .../services/sim-orchestrator.service.ts | 6 +- .../services/sim-usage.service.ts | 2 +- apps/bff/tsconfig.json | 8 + .../components/SimDetailsCard.tsx | 81 +++++----- apps/portal/tsconfig.json | 4 + packages/contracts/package.json | 54 +++++++ packages/contracts/src/billing/index.ts | 1 + packages/contracts/src/billing/invoice.ts | 49 ++++++ packages/contracts/src/freebit/index.ts | 1 + packages/contracts/src/freebit/requests.ts | 46 ++++++ packages/contracts/src/index.ts | 13 ++ packages/contracts/src/orders/fulfillment.ts | 20 +++ packages/contracts/src/orders/index.ts | 1 + packages/contracts/src/payments/index.ts | 1 + packages/contracts/src/payments/payment.ts | 44 +++++ packages/contracts/src/sim/index.ts | 1 + packages/contracts/src/sim/types.ts | 54 +++++++ packages/contracts/src/subscriptions/index.ts | 1 + .../src/subscriptions/subscription.ts | 43 +++++ packages/contracts/tsconfig.json | 16 ++ packages/domain/package.json | 2 + packages/domain/src/entities/invoice.ts | 22 ++- packages/domain/src/entities/payment.ts | 50 ++---- packages/domain/src/entities/subscription.ts | 18 ++- .../domain/src/validation/shared/entities.ts | 113 ++++--------- packages/domain/tsconfig.json | 7 +- packages/integrations/freebit/package.json | 38 +++++ packages/integrations/freebit/src/index.ts | 4 + .../integrations/freebit/src/mappers/index.ts | 1 + .../freebit/src/mappers/sim.mapper.ts | 133 +++++++++++++++ .../freebit/src/utils/data-utils.ts | 46 ++++++ .../integrations/freebit/src/utils/index.ts | 1 + packages/integrations/freebit/tsconfig.json | 28 ++++ packages/integrations/whmcs/package.json | 38 +++++ packages/integrations/whmcs/src/index.ts | 2 + .../integrations/whmcs/src/mappers/index.ts | 4 + .../whmcs/src/mappers/invoice.mapper.ts | 85 ++++++++++ .../whmcs/src/mappers/order.mapper.ts | 85 ++++++++++ .../whmcs/src/mappers/payment.mapper.ts | 94 +++++++++++ .../whmcs/src/mappers/subscription.mapper.ts | 129 +++++++++++++++ .../whmcs/src/utils/data-utils.ts | 28 ++++ packages/integrations/whmcs/tsconfig.json | 28 ++++ packages/schemas/package.json | 53 ++++++ packages/schemas/src/billing/index.ts | 1 + .../schemas/src/billing/invoice.schema.ts | 60 +++++++ packages/schemas/src/index.ts | 11 ++ .../integrations/freebit/account.schema.ts | 47 ++++++ .../schemas/src/integrations/freebit/index.ts | 4 + .../src/integrations/freebit/quota.schema.ts | 21 +++ .../freebit/requests/account.schema.ts | 17 ++ .../integrations/freebit/requests/index.ts | 3 + .../freebit/requests/plan-change.schema.ts | 28 ++++ .../freebit/requests/topup.schema.ts | 13 ++ .../integrations/freebit/traffic.schema.ts | 17 ++ packages/schemas/src/integrations/index.ts | 4 + .../schemas/src/integrations/whmcs/index.ts | 3 + .../src/integrations/whmcs/invoice.schema.ts | 47 ++++++ .../src/integrations/whmcs/payment.schema.ts | 29 ++++ .../src/integrations/whmcs/product.schema.ts | 70 ++++++++ packages/schemas/src/payments/index.ts | 1 + .../schemas/src/payments/payment.schema.ts | 67 ++++++++ packages/schemas/src/sim/index.ts | 1 + packages/schemas/src/sim/sim.schema.ts | 70 ++++++++ packages/schemas/src/subscriptions/index.ts | 1 + .../src/subscriptions/subscription.schema.ts | 60 +++++++ packages/schemas/tsconfig.json | 20 +++ tsconfig.json | 4 + 79 files changed, 2028 insertions(+), 837 deletions(-) delete mode 100644 apps/bff/src/integrations/whmcs/transformers/validators/transformation-validator.ts create mode 100644 packages/contracts/package.json create mode 100644 packages/contracts/src/billing/index.ts create mode 100644 packages/contracts/src/billing/invoice.ts create mode 100644 packages/contracts/src/freebit/index.ts create mode 100644 packages/contracts/src/freebit/requests.ts create mode 100644 packages/contracts/src/index.ts create mode 100644 packages/contracts/src/orders/fulfillment.ts create mode 100644 packages/contracts/src/orders/index.ts create mode 100644 packages/contracts/src/payments/index.ts create mode 100644 packages/contracts/src/payments/payment.ts create mode 100644 packages/contracts/src/sim/index.ts create mode 100644 packages/contracts/src/sim/types.ts create mode 100644 packages/contracts/src/subscriptions/index.ts create mode 100644 packages/contracts/src/subscriptions/subscription.ts create mode 100644 packages/contracts/tsconfig.json create mode 100644 packages/integrations/freebit/package.json create mode 100644 packages/integrations/freebit/src/index.ts create mode 100644 packages/integrations/freebit/src/mappers/index.ts create mode 100644 packages/integrations/freebit/src/mappers/sim.mapper.ts create mode 100644 packages/integrations/freebit/src/utils/data-utils.ts create mode 100644 packages/integrations/freebit/src/utils/index.ts create mode 100644 packages/integrations/freebit/tsconfig.json create mode 100644 packages/integrations/whmcs/package.json create mode 100644 packages/integrations/whmcs/src/index.ts create mode 100644 packages/integrations/whmcs/src/mappers/index.ts create mode 100644 packages/integrations/whmcs/src/mappers/invoice.mapper.ts create mode 100644 packages/integrations/whmcs/src/mappers/order.mapper.ts create mode 100644 packages/integrations/whmcs/src/mappers/payment.mapper.ts create mode 100644 packages/integrations/whmcs/src/mappers/subscription.mapper.ts create mode 100644 packages/integrations/whmcs/src/utils/data-utils.ts create mode 100644 packages/integrations/whmcs/tsconfig.json create mode 100644 packages/schemas/package.json create mode 100644 packages/schemas/src/billing/index.ts create mode 100644 packages/schemas/src/billing/invoice.schema.ts create mode 100644 packages/schemas/src/index.ts create mode 100644 packages/schemas/src/integrations/freebit/account.schema.ts create mode 100644 packages/schemas/src/integrations/freebit/index.ts create mode 100644 packages/schemas/src/integrations/freebit/quota.schema.ts create mode 100644 packages/schemas/src/integrations/freebit/requests/account.schema.ts create mode 100644 packages/schemas/src/integrations/freebit/requests/index.ts create mode 100644 packages/schemas/src/integrations/freebit/requests/plan-change.schema.ts create mode 100644 packages/schemas/src/integrations/freebit/requests/topup.schema.ts create mode 100644 packages/schemas/src/integrations/freebit/traffic.schema.ts create mode 100644 packages/schemas/src/integrations/index.ts create mode 100644 packages/schemas/src/integrations/whmcs/index.ts create mode 100644 packages/schemas/src/integrations/whmcs/invoice.schema.ts create mode 100644 packages/schemas/src/integrations/whmcs/payment.schema.ts create mode 100644 packages/schemas/src/integrations/whmcs/product.schema.ts create mode 100644 packages/schemas/src/payments/index.ts create mode 100644 packages/schemas/src/payments/payment.schema.ts create mode 100644 packages/schemas/src/sim/index.ts create mode 100644 packages/schemas/src/sim/sim.schema.ts create mode 100644 packages/schemas/src/subscriptions/index.ts create mode 100644 packages/schemas/src/subscriptions/subscription.schema.ts create mode 100644 packages/schemas/tsconfig.json 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" }