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.

This commit is contained in:
barsa 2025-10-03 13:19:26 +09:00
parent eded58ab93
commit 93e28fc20d
79 changed files with 2028 additions and 837 deletions

View File

@ -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 {

View File

@ -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);
}
/**

View File

@ -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<SimDetails> {
try {
const request: Omit<FreebitAccountDetailsRequest, "authKey"> = {
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<SimUsage> {
try {
const request: Omit<FreebitTrafficInfoRequest, "authKey"> = { 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<void> {
try {
const quotaKb = Math.round(quotaMb * 1024);
const baseRequest: Omit<FreebitTopUpRequest, "authKey"> = {
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<FreebitTopUpResponse, typeof request>(
endpoint,
@ -220,11 +220,18 @@ export class FreebitOperationsService {
options: { assignGlobalIp?: boolean; scheduledAt?: string } = {}
): Promise<{ ipv4?: string; ipv6?: string }> {
try {
const request: Omit<FreebitPlanChangeRequest, "authKey"> = {
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<void> {
try {
const request: Omit<FreebitAddSpecRequest, "authKey"> = { account };
const request = freebitAddSpecRequestSchema.parse({
account,
specCode: "FEATURES",
networkType: features.networkType,
});
const payload: Record<string, unknown> = {
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<FreebitAddSpecResponse, typeof request>(
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<FreebitAddSpecResponse, typeof payload>(
"/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<void> {
try {
const request: Omit<FreebitCancelPlanRequest, "authKey"> = {
const parsed = freebitCancelPlanRequestSchema.parse({
account,
runTime: scheduledAt,
runDate: scheduledAt,
});
const request = {
account: parsed.account,
runTime: parsed.runDate,
};
await this.client.makeAuthenticatedRequest<FreebitCancelPlanResponse, typeof request>(
@ -344,7 +369,7 @@ export class FreebitOperationsService {
*/
async reissueEsimProfile(account: string): Promise<void> {
try {
const request: Omit<FreebitEsimReissueRequest, "authKey"> = {
const request = {
requestDatas: [{ kind: "MVNO", account }],
};
@ -373,12 +398,20 @@ export class FreebitOperationsService {
options: { oldProductNumber?: string; oldEid?: string; planCode?: string } = {}
): Promise<void> {
try {
const parsed = freebitEsimReissueRequestSchema.parse({
account,
newEid,
oldEid: options.oldEid,
planCode: options.planCode,
oldProductNumber: options.oldProductNumber,
});
const request: Omit<FreebitEsimAddAccountRequest, "authKey"> = {
aladinOperated: "20",
account,
eid: newEid,
account: parsed.account,
eid: parsed.newEid,
addKind: "R",
planCode: options.planCode,
planCode: parsed.planCode,
};
await this.client.makeAuthenticatedRequest<FreebitEsimAddAccountResponse, typeof request>(
@ -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);

View File

@ -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}`);
}
}

View File

@ -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;
}
/**

View File

@ -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<string, string> | undefined {
if (!customFields || !Array.isArray(customFields) || customFields.length === 0) {
return undefined;
}
try {
const fields: Record<string, string> = {};
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<string, Subscription["status"]> = {
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<string, Subscription["cycle"]> = {
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
*/

View File

@ -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
) {}
/**

View File

@ -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());
}
}

View File

@ -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,

View File

@ -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;
}
/**

View File

@ -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,

View File

@ -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 {

View File

@ -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,

View File

@ -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";

View File

@ -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"],

View File

@ -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({
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">Voice Mail (¥300/month)</span>
<span
className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.voiceMailEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
className={`text-sm font-semibold px-2 py-1 rounded-full ${voiceMailEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
>
{simDetails.voiceMailEnabled ? "Enabled" : "Disabled"}
{voiceMailEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">Call Waiting (¥300/month)</span>
<span
className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.callWaitingEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
className={`text-sm font-semibold px-2 py-1 rounded-full ${callWaitingEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
>
{simDetails.callWaitingEnabled ? "Enabled" : "Disabled"}
{callWaitingEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div className="flex justify-between items-center py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">International Roaming</span>
<span
className={`text-sm font-semibold px-2 py-1 rounded-full ${simDetails.internationalRoamingEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
className={`text-sm font-semibold px-2 py-1 rounded-full ${internationalRoamingEnabled ? "bg-green-100 text-green-800" : "bg-gray-100 text-gray-600"}`}
>
{simDetails.internationalRoamingEnabled ? "Enabled" : "Disabled"}
{internationalRoamingEnabled ? "Enabled" : "Disabled"}
</span>
</div>
<div className="flex justify-between items-center py-2 px-3 bg-blue-50 rounded-lg">
@ -259,7 +251,7 @@ export function SimDetailsCard({
<div>
<h3 className="text-lg font-medium text-gray-900">Physical SIM Details</h3>
<p className="text-sm text-gray-500">
{formatPlan(simDetails.planCode)} {`${simDetails.size} SIM`}
{formatPlan(simDetails.planCode)} {`${sizeLabel ?? "Unknown"} SIM`}
</p>
</div>
</div>
@ -335,22 +327,18 @@ export function SimDetailsCard({
<div className="flex items-center space-x-4">
<div className="flex items-center">
<SignalIcon
className={`h-4 w-4 mr-1 ${simDetails.hasVoice ? "text-green-500" : "text-gray-400"}`}
className={`h-4 w-4 mr-1 ${hasVoice ? "text-green-500" : "text-gray-400"}`}
/>
<span
className={`text-sm ${simDetails.hasVoice ? "text-green-600" : "text-gray-500"}`}
>
Voice {simDetails.hasVoice ? "Enabled" : "Disabled"}
<span className={`text-sm ${hasVoice ? "text-green-600" : "text-gray-500"}`}>
Voice {hasVoice ? "Enabled" : "Disabled"}
</span>
</div>
<div className="flex items-center">
<DevicePhoneMobileIcon
className={`h-4 w-4 mr-1 ${simDetails.hasSms ? "text-green-500" : "text-gray-400"}`}
className={`h-4 w-4 mr-1 ${hasSms ? "text-green-500" : "text-gray-400"}`}
/>
<span
className={`text-sm ${simDetails.hasSms ? "text-green-600" : "text-gray-500"}`}
>
SMS {simDetails.hasSms ? "Enabled" : "Disabled"}
<span className={`text-sm ${hasSms ? "text-green-600" : "text-gray-500"}`}>
SMS {hasSms ? "Enabled" : "Disabled"}
</span>
</div>
</div>
@ -359,11 +347,14 @@ export function SimDetailsCard({
<div>
<label className="text-xs text-gray-500">IP Address</label>
<div className="space-y-1">
{simDetails.ipv4 && (
<p className="text-sm font-mono text-gray-900">IPv4: {simDetails.ipv4}</p>
{ipv4Address && (
<p className="text-sm font-mono text-gray-900">IPv4: {ipv4Address}</p>
)}
{simDetails.ipv6 && (
<p className="text-sm font-mono text-gray-900">IPv6: {simDetails.ipv6}</p>
{ipv6Address && (
<p className="text-sm font-mono text-gray-900">IPv6: {ipv6Address}</p>
)}
{!ipv4Address && !ipv6Address && (
<p className="text-sm text-gray-500">No IP assigned</p>
)}
</div>
</div>

View File

@ -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"],

View File

@ -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"
}
}

View File

@ -0,0 +1 @@
export * from "./invoice";

View File

@ -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;
}

View File

@ -0,0 +1 @@
export * from "./requests";

View File

@ -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;
}

View File

@ -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";

View File

@ -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[];
}

View File

@ -0,0 +1 @@
export * from "./fulfillment";

View File

@ -0,0 +1 @@
export * from "./payment";

View File

@ -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<string, unknown>;
}
export interface PaymentGatewayList {
gateways: PaymentGateway[];
totalCount: number;
}

View File

@ -0,0 +1 @@
export * from "./types";

View File

@ -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[];
}

View File

@ -0,0 +1 @@
export * from "./subscription";

View File

@ -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<string, string>;
orderNumber?: string;
groupName?: string;
paymentMethod?: string;
serverName?: string;
}
export interface SubscriptionList {
subscriptions: Subscription[];
totalCount: number;
}

View File

@ -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"]
}

View File

@ -28,6 +28,8 @@
"typescript": "^5.9.2"
},
"dependencies": {
"@customer-portal/contracts": "workspace:*",
"@customer-portal/schemas": "workspace:*",
"zod": "^4.1.9"
}
}

View File

@ -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;

View File

@ -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<string, unknown>;
}
export interface PaymentGatewayList {
gateways: PaymentGateway[];
totalCount: number;
}
export interface InvoicePaymentLink {
url: string;
expiresAt: string;

View File

@ -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;

View File

@ -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,

View File

@ -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"]

View File

@ -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:*"
}
}

View File

@ -0,0 +1,4 @@
export * as FreebitMappers from "./mappers";
export * as FreebitUtils from "./utils";
export * from "./mappers";
export * from "./utils";

View File

@ -0,0 +1 @@
export * from "./sim.mapper";

View File

@ -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);
}

View File

@ -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, "");
}

View File

@ -0,0 +1 @@
export * from "./data-utils";

View File

@ -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" }
]
}

View File

@ -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:*"
}
}

View File

@ -0,0 +1,2 @@
export * as WhmcsMappers from "./mappers";
export * from "./mappers";

View File

@ -0,0 +1,4 @@
export * from "./invoice.mapper";
export * from "./subscription.mapper";
export * from "./payment.mapper";
export * from "./order.mapper";

View File

@ -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<string, Invoice["status"]> = {
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);
}

View File

@ -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

View File

@ -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<string, PaymentMethod["type"]> = {
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<string, PaymentGateway["type"]> = {
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);
}

View File

@ -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<string, Subscription["status"]> = {
active: "Active",
inactive: "Inactive",
pending: "Pending",
cancelled: "Cancelled",
canceled: "Cancelled",
terminated: "Terminated",
completed: "Completed",
suspended: "Suspended",
fraud: "Cancelled",
};
const cycleMap: Record<string, Subscription["cycle"]> = {
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<string, string> | undefined {
if (!raw) return undefined;
const container = whmcsCustomFieldsContainerSchema.safeParse(raw);
if (!container.success) {
return undefined;
}
const customfield = container.data.customfield;
const fieldsArray = Array.isArray(customfield) ? customfield : [customfield];
const entries = fieldsArray.reduce<Record<string, string>>((acc, field) => {
if (field?.name && field.value) {
acc[field.name] = field.value;
}
return acc;
}, {});
return Object.keys(entries).length > 0 ? entries : undefined;
}
function resolveAmount(product: z.infer<typeof whmcsProductSchema>): 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);
}

View File

@ -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);
}
}

View File

@ -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" }
]
}

View File

@ -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"
}
}

View File

@ -0,0 +1 @@
export * from "./invoice.schema";

View File

@ -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<InvoiceItem>;
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<Invoice>;
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<InvoiceList>;
export type InvoiceSchema = typeof invoiceSchema;
export type InvoiceItemSchema = typeof invoiceItemSchema;
export type InvoiceListSchema = typeof invoiceListSchema;

View File

@ -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";

View File

@ -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;

View File

@ -0,0 +1,4 @@
export * from "./account.schema";
export * from "./traffic.schema";
export * from "./quota.schema";
export * as FreebitRequestSchemas from "./requests";

View File

@ -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;

View File

@ -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"),
});

View File

@ -0,0 +1,3 @@
export * from "./topup.schema";
export * from "./account.schema";
export * from "./plan-change.schema";

View File

@ -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(),
});

View File

@ -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(),
});

View File

@ -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;

View File

@ -0,0 +1,4 @@
export * as WhmcsSchemas from "./whmcs";
export * as FreebitSchemas from "./freebit";
export * from "./whmcs";
export * from "./freebit";

View File

@ -0,0 +1,3 @@
export * from "./invoice.schema";
export * from "./product.schema";
export * from "./payment.schema";

View File

@ -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;

View File

@ -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;

View File

@ -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;

View File

@ -0,0 +1 @@
export * from "./payment.schema";

View File

@ -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<PaymentMethod>;
export const paymentMethodListSchema = z.object({
paymentMethods: z.array(paymentMethodSchema),
totalCount: z.number().int().nonnegative(),
}) satisfies z.ZodType<PaymentMethodList>;
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<PaymentGateway>;
export const paymentGatewayListSchema = z.object({
gateways: z.array(paymentGatewaySchema),
totalCount: z.number().int().nonnegative(),
}) satisfies z.ZodType<PaymentGatewayList>;
export type PaymentMethodSchema = typeof paymentMethodSchema;
export type PaymentGatewaySchema = typeof paymentGatewaySchema;

View File

@ -0,0 +1 @@
export * from "./sim.schema";

View File

@ -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<SimDetails>;
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<SimUsage>;
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<SimTopUpHistoryEntry>;
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<SimTopUpHistory>;
export type SimDetailsSchema = typeof simDetailsSchema;
export type SimUsageSchema = typeof simUsageSchema;
export type SimTopUpHistorySchema = typeof simTopUpHistorySchema;

View File

@ -0,0 +1 @@
export * from "./subscription.schema";

View File

@ -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<Subscription>;
export const subscriptionListSchema = z.object({
subscriptions: z.array(subscriptionSchema),
totalCount: z.number().int().nonnegative(),
}) satisfies z.ZodType<SubscriptionList>;
export type SubscriptionSchema = typeof subscriptionSchema;
export type SubscriptionListSchema = typeof subscriptionListSchema;

View File

@ -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"]
}

View File

@ -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" }