Refactor import paths for AlertBanner, AsyncBlock, and other components in the portal to enhance module structure and maintainability. Remove unused components and streamline imports across various views and features, ensuring consistent file organization.

This commit is contained in:
barsa 2025-09-25 15:54:54 +09:00
parent 3da96d0c84
commit 065e2f9acf
70 changed files with 1165 additions and 1104 deletions

View File

@ -5,6 +5,7 @@ import type {
SimUsage,
SimTopUpHistory,
} from "./interfaces/freebit.types";
import type { MnpData } from "@customer-portal/domain";
@Injectable()
export class FreebitService {
@ -116,7 +117,7 @@ export class FreebitService {
simType: "eSIM" | "Physical SIM";
activationType: "Immediate" | "Scheduled";
scheduledAt?: string;
mnp?: any;
mnp?: MnpData;
}): Promise<void> {
// For eSIM, use the enhanced reissue method
if (params.simType === "eSIM") {

View File

@ -1,7 +1,4 @@
// Export all Freebit services
export { FreebitAuthService } from "./freebit-auth.service";
export { FreebitClientService } from "./freebit-client.service";
export { FreebitMapperService } from "./freebit-mapper.service";
export { FreebitOperationsService } from "./freebit-operations.service";
export { FreebitOrchestratorService } from "./freebit-orchestrator.service";
export { FreebitError } from "./freebit-error.service";
export { FreebitOrchestratorService } from './freebit-orchestrator.service';
export { FreebitMapperService } from './freebit-mapper.service';
export { FreebitOperationsService } from './freebit-operations.service';

View File

@ -30,6 +30,13 @@ export class WhmcsConfigService {
return this.accessKey;
}
/**
* Get the base URL for WHMCS API
*/
getBaseUrl(): string {
return this.config.baseUrl;
}
/**
* Check if admin authentication is available
*/

View File

@ -1,16 +0,0 @@
// Main orchestrator service
export { WhmcsConnectionOrchestratorService } from "./services/whmcs-connection-orchestrator.service";
// Individual services
export { WhmcsConfigService } from "./config/whmcs-config.service";
export { WhmcsHttpClientService } from "./services/whmcs-http-client.service";
export { WhmcsErrorHandlerService } from "./services/whmcs-error-handler.service";
export { WhmcsApiMethodsService } from "./services/whmcs-api-methods.service";
// Types
export type {
WhmcsApiConfig,
WhmcsRequestOptions,
WhmcsRetryConfig,
WhmcsConnectionStats,
} from "./types/connection.types";

View File

@ -9,25 +9,13 @@ import type {
WhmcsAddClientResponse,
WhmcsCatalogProductsResponse,
WhmcsPayMethodsResponse,
WhmcsAddPayMethodResponse,
WhmcsPaymentGatewaysResponse,
WhmcsCreateInvoiceResponse,
WhmcsUpdateInvoiceResponse,
WhmcsCapturePaymentResponse,
WhmcsAddCreditResponse,
WhmcsAddInvoicePaymentResponse,
WhmcsGetInvoicesParams,
WhmcsGetClientsProductsParams,
WhmcsCreateSsoTokenParams,
WhmcsValidateLoginParams,
WhmcsAddClientParams,
WhmcsGetPayMethodsParams,
WhmcsAddPayMethodParams,
WhmcsCreateInvoiceParams,
WhmcsUpdateInvoiceParams,
WhmcsCapturePaymentParams,
WhmcsAddCreditParams,
WhmcsAddInvoicePaymentParams,
} from "../../types/whmcs-api.types";
import { WhmcsHttpClientService } from "./whmcs-http-client.service";
import { WhmcsConfigService } from "../config/whmcs-config.service";
@ -122,13 +110,6 @@ export class WhmcsApiMethodsService {
return this.makeRequest("GetInvoice", { invoiceid: invoiceId });
}
async createInvoice(params: WhmcsCreateInvoiceParams): Promise<WhmcsCreateInvoiceResponse> {
return this.makeRequest("CreateInvoice", params);
}
async updateInvoice(params: WhmcsUpdateInvoiceParams): Promise<WhmcsUpdateInvoiceResponse> {
return this.makeRequest("UpdateInvoice", params);
}
// ==========================================
// PRODUCT/SUBSCRIPTION API METHODS
@ -150,27 +131,11 @@ export class WhmcsApiMethodsService {
return this.makeRequest("GetPayMethods", params);
}
async addPaymentMethod(params: WhmcsAddPayMethodParams): Promise<WhmcsAddPayMethodResponse> {
return this.makeRequest("AddPayMethod", params);
}
async getPaymentGateways(): Promise<WhmcsPaymentGatewaysResponse> {
return this.makeRequest("GetPaymentMethods", {});
}
async capturePayment(params: WhmcsCapturePaymentParams): Promise<WhmcsCapturePaymentResponse> {
return this.makeRequest("CapturePayment", params);
}
async addCredit(params: WhmcsAddCreditParams): Promise<WhmcsAddCreditResponse> {
return this.makeRequest("AddCredit", params);
}
async addInvoicePayment(
params: WhmcsAddInvoicePaymentParams
): Promise<WhmcsAddInvoicePaymentResponse> {
return this.makeRequest("AddInvoicePayment", params);
}
// ==========================================
// SSO API METHODS
@ -180,32 +145,9 @@ export class WhmcsApiMethodsService {
return this.makeRequest("CreateSsoToken", params);
}
// ==========================================
// ADMIN API METHODS (require admin auth)
// ==========================================
async acceptOrder(orderId: number): Promise<{ result: string }> {
if (!this.configService.hasAdminAuth()) {
throw new Error("Admin authentication required for AcceptOrder");
}
return this.makeRequest<{ result: string }>(
"AcceptOrder",
{ orderid: orderId },
{ useAdminAuth: true }
);
}
async cancelOrder(orderId: number): Promise<{ result: string }> {
if (!this.configService.hasAdminAuth()) {
throw new Error("Admin authentication required for CancelOrder");
}
return this.makeRequest<{ result: string }>(
"CancelOrder",
{ orderid: orderId },
{ useAdminAuth: true }
);
async getProducts() {
return this.makeRequest("GetProducts", {});
}
// ==========================================

View File

@ -7,7 +7,13 @@ import { WhmcsErrorHandlerService } from "./whmcs-error-handler.service";
import { WhmcsApiMethodsService } from "./whmcs-api-methods.service";
import type {
WhmcsApiResponse,
WhmcsErrorResponse
WhmcsErrorResponse,
WhmcsAddClientParams,
WhmcsValidateLoginParams,
WhmcsGetInvoicesParams,
WhmcsGetClientsProductsParams,
WhmcsGetPayMethodsParams,
WhmcsCreateSsoTokenParams,
} from "../../types/whmcs-api.types";
import type {
WhmcsRequestOptions,
@ -36,7 +42,7 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
// Test connection
const isAvailable = await this.apiMethods.isAvailable();
if (isAvailable) {
this.logger.info("WHMCS connection established successfully");
this.logger.log("WHMCS connection established successfully");
} else {
this.logger.warn("WHMCS connection test failed - service may be unavailable");
}
@ -108,15 +114,15 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
return this.apiMethods.getClientDetailsByEmail(email);
}
async updateClient(clientId: number, updateData: any) {
async updateClient(clientId: number, updateData: Record<string, unknown>) {
return this.apiMethods.updateClient(clientId, updateData);
}
async addClient(params: any) {
async addClient(params: WhmcsAddClientParams) {
return this.apiMethods.addClient(params);
}
async validateLogin(params: any) {
async validateLogin(params: WhmcsValidateLoginParams) {
return this.apiMethods.validateLogin(params);
}
@ -124,7 +130,7 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
// INVOICE API METHODS
// ==========================================
async getInvoices(params: any = {}) {
async getInvoices(params: WhmcsGetInvoicesParams = {}) {
return this.apiMethods.getInvoices(params);
}
@ -132,19 +138,12 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
return this.apiMethods.getInvoice(invoiceId);
}
async createInvoice(params: any) {
return this.apiMethods.createInvoice(params);
}
async updateInvoice(params: any) {
return this.apiMethods.updateInvoice(params);
}
// ==========================================
// PRODUCT/SUBSCRIPTION API METHODS
// ==========================================
async getClientsProducts(params: any) {
async getClientsProducts(params: WhmcsGetClientsProductsParams) {
return this.apiMethods.getClientsProducts(params);
}
@ -152,53 +151,44 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
return this.apiMethods.getCatalogProducts();
}
async getProducts() {
return this.apiMethods.getProducts();
}
// ==========================================
// PAYMENT API METHODS
// ==========================================
async getPaymentMethods(params: any) {
async getPaymentMethods(params: WhmcsGetPayMethodsParams) {
return this.apiMethods.getPaymentMethods(params);
}
async addPaymentMethod(params: any) {
return this.apiMethods.addPaymentMethod(params);
// Legacy method name for backward compatibility
async getPayMethods(params: WhmcsGetPayMethodsParams) {
return this.getPaymentMethods(params);
}
async getPaymentGateways() {
return this.apiMethods.getPaymentGateways();
}
async capturePayment(params: any) {
return this.apiMethods.capturePayment(params);
}
async addCredit(params: any) {
return this.apiMethods.addCredit(params);
}
async addInvoicePayment(params: any) {
return this.apiMethods.addInvoicePayment(params);
}
// ==========================================
// SSO API METHODS
// ==========================================
async createSsoToken(params: any) {
async createSsoToken(params: WhmcsCreateSsoTokenParams) {
return this.apiMethods.createSsoToken(params);
}
// ==========================================
// ADMIN API METHODS
// UTILITY METHODS
// ==========================================
async acceptOrder(orderId: number) {
return this.apiMethods.acceptOrder(orderId);
getBaseUrl(): string {
return this.configService.getBaseUrl();
}
async cancelOrder(orderId: number) {
return this.apiMethods.cancelOrder(orderId);
}
// ==========================================
// UTILITY METHODS

View File

@ -7,9 +7,6 @@ import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
import {
WhmcsGetInvoicesParams,
WhmcsCreateInvoiceParams,
WhmcsUpdateInvoiceParams,
WhmcsCapturePaymentParams,
WhmcsInvoicesResponse,
} from "../types/whmcs-api.types";
@ -203,224 +200,6 @@ export class WhmcsInvoiceService {
this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`);
}
// ========================================
// NEW: Invoice Creation Methods
// ========================================
/**
* Create a new invoice for a client
*/
async createInvoice(params: {
clientId: number;
description: string;
amount: number;
currency?: string;
dueDate?: Date;
notes?: string;
}): Promise<{ id: number; number: string; total: number; status: string }> {
try {
const dueDateStr = params.dueDate
? params.dueDate.toISOString().split("T")[0]
: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]; // 7 days from now
const whmcsParams: WhmcsCreateInvoiceParams = {
userid: params.clientId,
status: "Unpaid",
sendnotification: false, // Don't send email notification automatically
duedate: dueDateStr,
notes: params.notes,
itemdescription1: params.description,
itemamount1: params.amount,
itemtaxed1: false, // No tax for data top-ups for now
};
const response = await this.connectionService.createInvoice(whmcsParams);
if (response.result !== "success") {
throw new Error(`WHMCS invoice creation failed: ${response.message}`);
}
this.logger.log(`Created WHMCS invoice ${response.invoiceid} for client ${params.clientId}`, {
invoiceId: response.invoiceid,
amount: params.amount,
description: params.description,
});
return {
id: response.invoiceid,
number: `INV-${response.invoiceid}`,
total: params.amount,
status: response.status,
};
} catch (error) {
this.logger.error(`Failed to create invoice for client ${params.clientId}`, {
error: getErrorMessage(error),
params,
});
throw error;
}
}
/**
* Update an existing invoice
*/
async updateInvoice(params: {
invoiceId: number;
status?:
| "Draft"
| "Unpaid"
| "Paid"
| "Cancelled"
| "Refunded"
| "Collections"
| "Payment Pending";
dueDate?: Date;
notes?: string;
}): Promise<{ success: boolean; message?: string }> {
try {
const whmcsParams: WhmcsUpdateInvoiceParams = {
invoiceid: params.invoiceId,
status: params.status,
duedate: params.dueDate ? params.dueDate.toISOString().split("T")[0] : undefined,
notes: params.notes,
};
const response = await this.connectionService.updateInvoice(whmcsParams);
if (response.result !== "success") {
throw new Error(`WHMCS invoice update failed: ${response.message}`);
}
this.logger.log(`Updated WHMCS invoice ${params.invoiceId}`, {
invoiceId: params.invoiceId,
status: params.status,
notes: params.notes,
});
return {
success: true,
message: response.message,
};
} catch (error) {
this.logger.error(`Failed to update invoice ${params.invoiceId}`, {
error: getErrorMessage(error),
params,
});
throw error;
}
}
/**
* Capture payment for an invoice using the client's default payment method
*/
async capturePayment(params: {
invoiceId: number;
amount: number;
currency?: string;
}): Promise<{ success: boolean; transactionId?: string; error?: string }> {
try {
const whmcsParams: WhmcsCapturePaymentParams = {
invoiceid: params.invoiceId,
};
const response = await this.connectionService.capturePayment(whmcsParams);
if (response.result === "success") {
this.logger.log(`Successfully captured payment for invoice ${params.invoiceId}`, {
invoiceId: params.invoiceId,
transactionId: response.transactionid,
amount: response.amount,
});
// Invalidate invoice cache since status changed
await this.cacheService.invalidateInvoice(`invoice-${params.invoiceId}`, params.invoiceId);
return {
success: true,
transactionId: response.transactionid,
};
} else {
this.logger.warn(`Payment capture failed for invoice ${params.invoiceId}`, {
invoiceId: params.invoiceId,
error: response.message || response.error,
});
// Return user-friendly error message instead of technical API error
const userFriendlyError = this.getUserFriendlyPaymentError(
response.message || response.error || "Unknown payment error"
);
return {
success: false,
error: userFriendlyError,
};
}
} catch (error) {
this.logger.error(`Failed to capture payment for invoice ${params.invoiceId}`, {
error: getErrorMessage(error),
params,
});
// Return user-friendly error message for exceptions
const userFriendlyError = this.getUserFriendlyPaymentError(getErrorMessage(error));
return {
success: false,
error: userFriendlyError,
};
}
}
/**
* Convert technical payment errors to user-friendly messages
*/
private getUserFriendlyPaymentError(technicalError: string): string {
if (!technicalError) {
return "Unable to process payment. Please try again or contact support.";
}
const errorLower = technicalError.toLowerCase();
// WHMCS API permission errors
if (errorLower.includes("invalid permissions") || errorLower.includes("not allowed")) {
return "Payment processing is temporarily unavailable. Please contact support for assistance.";
}
// Authentication/authorization errors
if (
errorLower.includes("unauthorized") ||
errorLower.includes("forbidden") ||
errorLower.includes("403")
) {
return "Payment processing is temporarily unavailable. Please contact support for assistance.";
}
// Network/timeout errors
if (
errorLower.includes("timeout") ||
errorLower.includes("network") ||
errorLower.includes("connection")
) {
return "Payment processing timed out. Please try again.";
}
// Payment method errors
if (
errorLower.includes("payment method") ||
errorLower.includes("card") ||
errorLower.includes("insufficient funds")
) {
return "Unable to process payment with your current payment method. Please check your payment details or try a different method.";
}
// Generic API errors
if (errorLower.includes("api") || errorLower.includes("http") || errorLower.includes("error")) {
return "Payment processing failed. Please try again or contact support if the issue persists.";
}
// Default fallback
return "Unable to process payment. Please try again or contact support.";
}
private transformInvoicesResponse(
response: WhmcsInvoicesResponse,

View File

@ -1,14 +0,0 @@
// Main orchestrator service
export { WhmcsTransformerOrchestratorService } from "./services/whmcs-transformer-orchestrator.service";
// Individual transformer services
export { InvoiceTransformerService } from "./services/invoice-transformer.service";
export { SubscriptionTransformerService } from "./services/subscription-transformer.service";
export { PaymentTransformerService } from "./services/payment-transformer.service";
// Utilities
export { DataUtils } from "./utils/data-utils";
export { StatusNormalizer } from "./utils/status-normalizer";
// Validators
export { TransformationValidator } from "./validators/transformation-validator";

View File

@ -75,7 +75,7 @@ export class InvoiceTransformerService {
const message = DataUtils.toErrorMessage(error);
this.logger.error(`Failed to transform invoice ${invoiceId}`, {
error: message,
whmcsData: DataUtils.sanitizeForLog(whmcsInvoice as Record<string, unknown>),
whmcsData: DataUtils.sanitizeForLog(whmcsInvoice as unknown as Record<string, unknown>),
});
throw new Error(`Failed to transform invoice: ${message}`);
}
@ -97,7 +97,7 @@ export class InvoiceTransformerService {
} catch (error) {
this.logger.warn("Failed to transform invoice items", {
error: DataUtils.toErrorMessage(error),
itemsData: DataUtils.sanitizeForLog(items as Record<string, unknown>),
itemsData: DataUtils.sanitizeForLog(items as unknown as Record<string, unknown>),
});
return [];
}
@ -109,9 +109,11 @@ export class InvoiceTransformerService {
private transformSingleInvoiceItem(item: Record<string, unknown>): InvoiceItem | null {
try {
const transformedItem: InvoiceItem = {
id: DataUtils.safeNumber(item.id, 0),
description: DataUtils.safeString(item.description, "Unknown Item"),
amount: DataUtils.parseAmount(item.amount),
quantity: DataUtils.safeNumber(item.qty, 1),
type: DataUtils.safeString(item.type, "Unknown"),
};
// Add service ID if available
@ -119,10 +121,8 @@ export class InvoiceTransformerService {
transformedItem.serviceId = DataUtils.safeNumber(item.relid);
}
// Add tax information if available
if (item.taxed === "1" || item.taxed === true) {
transformedItem.taxable = true;
}
// Note: taxable property is not part of the InvoiceItem schema
// Tax information is handled at the invoice level
return transformedItem;
} catch (error) {

View File

@ -26,7 +26,7 @@ export class PaymentTransformerService {
whmcsGateway.display_name || whmcsGateway.name,
whmcsGateway.name
),
type: DataUtils.safeString(whmcsGateway.type, "unknown"),
type: this.normalizeGatewayType(DataUtils.safeString(whmcsGateway.type, "manual")),
isActive: DataUtils.safeBoolean(whmcsGateway.active),
acceptsCreditCards: DataUtils.safeBoolean(whmcsGateway.accepts_credit_cards),
acceptsBankAccount: DataUtils.safeBoolean(whmcsGateway.accepts_bank_account),
@ -64,12 +64,11 @@ export class PaymentTransformerService {
}
const transformed: PaymentMethod = {
id: DataUtils.safeString(payMethodId),
id: DataUtils.safeNumber(payMethodId, 0),
type: this.normalizePaymentType(gatewayName),
gateway: DataUtils.safeString(gatewayName),
description: this.buildPaymentDescription(whmcsPayMethod),
gatewayName: DataUtils.safeString(gatewayName),
isDefault: DataUtils.safeBoolean(whmcsPayMethod.is_default || whmcsPayMethod.default),
isActive: DataUtils.safeBoolean(whmcsPayMethod.is_active ?? true), // Default to active if not specified
};
// Add credit card specific fields
@ -78,7 +77,7 @@ export class PaymentTransformerService {
}
if (cardType) {
transformed.cardType = DataUtils.safeString(cardType);
transformed.ccType = DataUtils.safeString(cardType);
}
if (expiryDate) {
@ -90,9 +89,8 @@ export class PaymentTransformerService {
transformed.accountType = DataUtils.safeString(whmcsPayMethod.account_type);
}
if (whmcsPayMethod.routing_number) {
transformed.routingNumber = DataUtils.safeString(whmcsPayMethod.routing_number);
}
// Note: routingNumber is not part of the PaymentMethod interface
// This would need to be added to the interface if needed
if (!this.validator.validatePaymentMethod(transformed)) {
throw new Error("Transformed payment method failed validation");
@ -257,4 +255,49 @@ export class PaymentTransformerService {
return results;
}
// ==========================================
// PRIVATE HELPER METHODS
// ==========================================
/**
* Normalize gateway type to match our enum
*/
private normalizeGatewayType(type: string): "merchant" | "thirdparty" | "tokenization" | "manual" {
const normalizedType = type.toLowerCase();
switch (normalizedType) {
case "merchant":
case "credit_card":
case "creditcard":
return "merchant";
case "thirdparty":
case "third_party":
case "external":
return "thirdparty";
case "tokenization":
case "token":
return "tokenization";
default:
return "manual";
}
}
/**
* Normalize payment method type to match our enum
*/
private normalizePaymentType(gatewayName?: string): "CreditCard" | "BankAccount" | "RemoteCreditCard" | "RemoteBankAccount" | "Manual" {
if (!gatewayName) return "Manual";
const normalized = gatewayName.toLowerCase();
if (normalized.includes("credit") || normalized.includes("card") || normalized.includes("visa") || normalized.includes("mastercard")) {
return "CreditCard";
}
if (normalized.includes("bank") || normalized.includes("ach") || normalized.includes("account")) {
return "BankAccount";
}
if (normalized.includes("remote") || normalized.includes("token")) {
return "RemoteCreditCard";
}
return "Manual";
}
}

View File

@ -314,32 +314,6 @@ export interface WhmcsGetPayMethodsParams {
[key: string]: unknown;
}
export interface WhmcsAddPayMethodParams {
clientid: number;
type: "CreditCard" | "BankAccount" | "RemoteCreditCard";
description: string;
gateway_name?: string;
// Credit Card specific
card_number?: string;
card_expiry?: string;
cvv?: string;
card_holder_name?: string;
// Bank Account specific
bank_name?: string;
account_type?: string;
routing_number?: string;
account_number?: string;
account_holder_name?: string;
// Remote/Tokenized
remote_token?: string;
// Billing info
billing_contact_id?: number;
[key: string]: unknown;
}
export interface WhmcsAddPayMethodResponse {
paymethodid: number;
}
// Payment Gateway Types
export interface WhmcsPaymentGateway {
@ -359,123 +333,7 @@ export interface WhmcsPaymentGatewaysResponse {
totalresults: number;
}
// ========================================
// NEW: Invoice Creation and Payment Capture Types
// ========================================
// CreateInvoice API Types
export interface WhmcsCreateInvoiceParams {
userid: number;
status?:
| "Draft"
| "Unpaid"
| "Paid"
| "Cancelled"
| "Refunded"
| "Collections"
| "Payment Pending";
sendnotification?: boolean;
paymentmethod?: string;
taxrate?: number;
taxrate2?: number;
date?: string; // YYYY-MM-DD format
duedate?: string; // YYYY-MM-DD format
notes?: string;
itemdescription1?: string;
itemamount1?: number;
itemtaxed1?: boolean;
itemdescription2?: string;
itemamount2?: number;
itemtaxed2?: boolean;
// Can have up to 24 line items (itemdescription1-24, itemamount1-24, itemtaxed1-24)
[key: string]: unknown;
}
export interface WhmcsCreateInvoiceResponse {
result: "success" | "error";
invoiceid: number;
status: string;
message?: string;
}
// UpdateInvoice API Types
export interface WhmcsUpdateInvoiceParams {
invoiceid: number;
status?:
| "Draft"
| "Unpaid"
| "Paid"
| "Cancelled"
| "Refunded"
| "Collections"
| "Payment Pending";
duedate?: string; // YYYY-MM-DD format
notes?: string;
[key: string]: unknown;
}
export interface WhmcsUpdateInvoiceResponse {
result: "success" | "error";
invoiceid: number;
status: string;
message?: string;
}
// CapturePayment API Types
export interface WhmcsCapturePaymentParams {
invoiceid: number;
cvv?: string;
cardnum?: string;
cccvv?: string;
cardtype?: string;
cardexp?: string;
// For existing payment methods
paymentmethodid?: number;
// Manual payment capture
transid?: string;
gateway?: string;
[key: string]: unknown;
}
export interface WhmcsCapturePaymentResponse {
result: "success" | "error";
invoiceid: number;
status: string;
transactionid?: string;
amount?: number;
fees?: number;
message?: string;
error?: string;
}
// AddCredit API Types (for refunds if needed)
export interface WhmcsAddCreditParams {
clientid: number;
description: string;
amount: number;
type?: "add" | "refund";
[key: string]: unknown;
}
export interface WhmcsAddCreditResponse {
result: "success" | "error";
creditid: number;
message?: string;
}
// AddInvoicePayment API Types (for manual payment recording)
export interface WhmcsAddInvoicePaymentParams {
invoiceid: number;
transid: string;
amount?: number;
fees?: number;
gateway: string;
date?: string; // YYYY-MM-DD HH:MM:SS format
noemail?: boolean;
[key: string]: unknown;
}
export interface WhmcsAddInvoicePaymentResponse {
result: "success" | "error";
message?: string;
}

View File

@ -341,53 +341,6 @@ export class WhmcsService {
return this.connectionService.getClientsProducts(params);
}
// ==========================================
// INVOICE CREATION AND PAYMENT OPERATIONS
// ==========================================
/**
* Create a new invoice for a client
*/
async createInvoice(params: {
clientId: number;
description: string;
amount: number;
currency?: string;
dueDate?: Date;
notes?: string;
}): Promise<{ id: number; number: string; total: number; status: string }> {
return this.invoiceService.createInvoice(params);
}
/**
* Update an existing invoice
*/
async updateInvoice(params: {
invoiceId: number;
status?:
| "Draft"
| "Unpaid"
| "Paid"
| "Cancelled"
| "Refunded"
| "Collections"
| "Payment Pending";
dueDate?: Date;
notes?: string;
}): Promise<{ success: boolean; message?: string }> {
return this.invoiceService.updateInvoice(params);
}
/**
* Capture payment for an invoice
*/
async capturePayment(params: {
invoiceId: number;
amount: number;
currency?: string;
}): Promise<{ success: boolean; transactionId?: string; error?: string }> {
return this.invoiceService.capturePayment(params);
}
// ==========================================
// ORDER OPERATIONS (delegate to OrderService)

View File

@ -0,0 +1,22 @@
// Main orchestrator service
export { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
// Individual services
export { InvoiceRetrievalService } from "./services/invoice-retrieval.service";
export { InvoiceOperationsService } from "./services/invoice-operations.service";
export { PaymentMethodsService } from "./services/payment-methods.service";
export { InvoiceHealthService } from "./services/invoice-health.service";
// Validators
export { InvoiceValidatorService } from "./validators/invoice-validator.service";
// Types
export type {
GetInvoicesOptions,
InvoiceValidationResult,
InvoiceServiceStats,
InvoiceHealthStatus,
InvoiceStatus,
PaginationOptions,
UserMappingInfo,
} from "./types/invoice-service.types";

View File

@ -3,10 +3,28 @@ import { InvoicesController } from "./invoices.controller";
import { InvoicesService } from "./invoices.service";
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module";
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
// New modular invoice services
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
import { InvoiceRetrievalService } from "./services/invoice-retrieval.service";
import { InvoiceOperationsService } from "./services/invoice-operations.service";
import { PaymentMethodsService } from "./services/payment-methods.service";
import { InvoiceHealthService } from "./services/invoice-health.service";
import { InvoiceValidatorService } from "./validators/invoice-validator.service";
@Module({
imports: [WhmcsModule, MappingsModule],
controllers: [InvoicesController],
providers: [InvoicesService],
providers: [
// Legacy service (now facade)
InvoicesService,
// New modular services
InvoicesOrchestratorService,
InvoiceRetrievalService,
InvoiceOperationsService,
PaymentMethodsService,
InvoiceHealthService,
InvoiceValidatorService,
],
exports: [InvoicesService, InvoicesOrchestratorService],
})
export class InvoicesModule {}

View File

@ -1,205 +1,67 @@
import {
Injectable,
NotFoundException,
Inject,
BadRequestException,
InternalServerErrorException,
} from "@nestjs/common";
import {
Invoice,
InvoiceItem,
InvoiceList,
InvoiceSsoLink,
Subscription,
PaymentMethodList,
PaymentGatewayList,
InvoicePaymentLink,
} from "@customer-portal/domain";
import { Injectable } from "@nestjs/common";
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { Logger } from "nestjs-pino";
import type {
Invoice,
InvoiceList,
InvoiceSsoLink,
InvoicePaymentLink,
PaymentMethodList,
PaymentGatewayList,
} from "@customer-portal/domain";
// Re-export the interface for backward compatibility
export interface GetInvoicesOptions {
page?: number;
limit?: number;
status?: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections";
}
/**
* Invoices Service - now acts as a facade to the orchestrator service
* Maintains backward compatibility while delegating to modular services
*/
@Injectable()
export class InvoicesService {
constructor(
private readonly orchestrator: InvoicesOrchestratorService,
private readonly whmcsService: WhmcsService,
private readonly mappingsService: MappingsService,
@Inject(Logger) private readonly logger: Logger
private readonly mappingsService: MappingsService
) {}
/**
* Get paginated invoices for a user
*/
async getInvoices(userId: string, options: GetInvoicesOptions = {}): Promise<InvoiceList> {
const { page = 1, limit = 10, status } = options;
try {
// Get WHMCS client ID from user mapping
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new NotFoundException("WHMCS client mapping not found");
}
// Validate pagination parameters
if (page < 1) {
throw new BadRequestException("Page must be greater than 0");
}
if (limit < 1 || limit > 100) {
throw new BadRequestException("Limit must be between 1 and 100");
}
// Fetch invoices from WHMCS
const invoiceList = await this.whmcsService.getInvoices(mapping.whmcsClientId, userId, {
page,
limit,
status,
});
this.logger.log(`Retrieved ${invoiceList.invoices.length} invoices for user ${userId}`, {
page,
limit,
status,
totalItems: invoiceList.pagination?.totalItems,
});
return invoiceList;
} catch (error) {
this.logger.error(`Failed to get invoices for user ${userId}`, {
error: getErrorMessage(error),
options,
});
if (error instanceof NotFoundException) {
throw error;
}
throw new InternalServerErrorException("Failed to retrieve invoices");
}
return this.orchestrator.getInvoices(userId, options);
}
/**
* Get individual invoice by ID
*/
async getInvoiceById(userId: string, invoiceId: number): Promise<Invoice> {
try {
// Validate invoice ID
if (!invoiceId || invoiceId < 1) {
throw new BadRequestException("Invalid invoice ID");
}
// Get WHMCS client ID from user mapping
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new NotFoundException("WHMCS client mapping not found");
}
// Fetch invoice from WHMCS
const invoice = await this.whmcsService.getInvoiceById(
mapping.whmcsClientId,
userId,
invoiceId
);
this.logger.log(`Retrieved invoice ${invoiceId} for user ${userId}`, {
invoiceNumber: invoice.number,
status: invoice.status,
total: invoice.total,
currency: invoice.currency,
});
return invoice;
} catch (error) {
this.logger.error(`Failed to get invoice ${invoiceId} for user ${userId}`, {
error: getErrorMessage(error),
});
if (error instanceof NotFoundException) {
throw error;
}
throw new InternalServerErrorException("Failed to retrieve invoice");
}
return this.orchestrator.getInvoiceById(userId, invoiceId);
}
/**
* Create SSO link for invoice viewing, PDF download, or direct payment
* Create SSO link for invoice management
*/
async createSsoLink(
userId: string,
invoiceId: number,
target: "view" | "download" | "pay" = "view"
): Promise<InvoiceSsoLink> {
try {
// Validate invoice ID
if (!invoiceId || invoiceId < 1) {
throw new BadRequestException("Invalid invoice ID");
}
// Get WHMCS client ID from user mapping
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new NotFoundException("WHMCS client mapping not found");
}
// Verify the invoice exists and belongs to this user
await this.getInvoiceById(userId, invoiceId);
// Determine the target path based on the requested action
let path: string;
switch (target) {
case "pay":
// Direct payment page using WHMCS Friendly URLs
path = `index.php?rp=/invoice/${invoiceId}/pay`;
break;
case "download":
// PDF download
path = `dl.php?type=i&id=${invoiceId}`;
break;
case "view":
default:
// Invoice view page
path = `viewinvoice.php?id=${invoiceId}`;
break;
}
// Create SSO token for the specific invoice
const ssoResult = await this.whmcsService.createSsoToken(
mapping.whmcsClientId,
"sso:custom_redirect",
path
);
const result: InvoiceSsoLink = {
url: ssoResult.url,
expiresAt: ssoResult.expiresAt,
};
this.logger.log(`Created SSO link for invoice ${invoiceId}, user ${userId}`, {
target,
path,
expiresAt: result.expiresAt,
});
return result;
} catch (error) {
this.logger.error(`Failed to create SSO link for invoice ${invoiceId}, user ${userId}`, {
error: getErrorMessage(error),
target,
});
if (error instanceof NotFoundException) {
throw error;
}
throw new InternalServerErrorException("Failed to create SSO link");
async createInvoiceSsoLink(userId: string, invoiceId?: number): Promise<InvoiceSsoLink> {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new Error("WHMCS client mapping not found");
}
const ssoResult = await this.whmcsService.createSsoToken(
mapping.whmcsClientId,
invoiceId ? `index.php?rp=/invoice/${invoiceId}` : undefined
);
return {
url: ssoResult.url,
expiresAt: ssoResult.expiresAt,
};
}
/**
@ -210,29 +72,7 @@ export class InvoicesService {
status: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections",
options: Pick<GetInvoicesOptions, "page" | "limit"> = {}
): Promise<InvoiceList> {
const { page = 1, limit = 10 } = options;
try {
// Validate status
const validStatuses = ["Paid", "Unpaid", "Cancelled", "Overdue", "Collections"] as const;
if (!validStatuses.includes(status)) {
throw new BadRequestException(
`Invalid status. Must be one of: ${validStatuses.join(", ")}`
);
}
return await this.getInvoices(userId, { page, limit, status });
} catch (error) {
this.logger.error(`Failed to get ${status} invoices for user ${userId}`, {
error: getErrorMessage(error),
options,
});
if (error instanceof NotFoundException) {
throw error;
}
throw new InternalServerErrorException("Failed to retrieve invoices");
}
return this.orchestrator.getInvoicesByStatus(userId, status, options);
}
/**
@ -242,7 +82,7 @@ export class InvoicesService {
userId: string,
options: Pick<GetInvoicesOptions, "page" | "limit"> = {}
): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Unpaid", options);
return this.orchestrator.getUnpaidInvoices(userId, options);
}
/**
@ -252,325 +92,124 @@ export class InvoicesService {
userId: string,
options: Pick<GetInvoicesOptions, "page" | "limit"> = {}
): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Overdue", options);
return this.orchestrator.getOverdueInvoices(userId, options);
}
/**
* Get invoice statistics for a user
* Get paid invoices for a user
*/
async getInvoiceStats(userId: string): Promise<{
total: number;
paid: number;
unpaid: number;
overdue: number;
totalAmount: number;
unpaidAmount: number;
currency: string;
}> {
try {
// Get all invoices (first 1000, should be enough for stats)
const invoiceList = await this.getInvoices(userId, {
page: 1,
limit: 1000,
});
const invoices = invoiceList.invoices;
if (invoices.length === 0) {
return {
total: 0,
paid: 0,
unpaid: 0,
overdue: 0,
totalAmount: 0,
unpaidAmount: 0,
currency: "JPY",
};
}
// Calculate statistics
const stats = {
total: invoices.length,
paid: invoices.filter((i: Invoice) => i.status === "Paid").length,
unpaid: invoices.filter((i: Invoice) => i.status === "Unpaid").length,
overdue: invoices.filter((i: Invoice) => i.status === "Overdue").length,
totalAmount: invoices.reduce((sum: number, i: Invoice) => sum + i.total, 0),
unpaidAmount: invoices
.filter((i: Invoice) => ["Unpaid", "Overdue"].includes(i.status))
.reduce((sum: number, i: Invoice) => sum + i.total, 0),
currency: invoices[0]?.currency || "JPY",
};
this.logger.log(`Generated invoice stats for user ${userId}`, stats);
return stats;
} catch (error) {
this.logger.error(`Failed to generate invoice stats for user ${userId}`, {
error: getErrorMessage(error),
});
if (error instanceof NotFoundException) {
throw error;
}
throw new InternalServerErrorException("Failed to generate invoice statistics");
}
async getPaidInvoices(
userId: string,
options: Pick<GetInvoicesOptions, "page" | "limit"> = {}
): Promise<InvoiceList> {
return this.orchestrator.getPaidInvoices(userId, options);
}
/**
* Get subscriptions related to an invoice
* Get cancelled invoices for a user
*/
async getInvoiceSubscriptions(userId: string, invoiceId: number): Promise<Subscription[]> {
try {
// Get the invoice with items
const invoice = await this.getInvoiceById(userId, invoiceId);
if (!invoice.items || invoice.items.length === 0) {
return [];
}
// Get WHMCS client ID from user mapping
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new NotFoundException("WHMCS client mapping not found");
}
// Get subscription IDs from invoice items
const subscriptionIds = invoice.items
.filter((item: InvoiceItem) => item.serviceId && item.serviceId > 0)
.map((item: InvoiceItem) => item.serviceId!);
if (subscriptionIds.length === 0) {
return [];
}
// Get all subscriptions for the user
const allSubscriptions = await this.whmcsService.getSubscriptions(
mapping.whmcsClientId,
userId
);
// Filter subscriptions that are referenced in the invoice
const relatedSubscriptions = allSubscriptions.subscriptions.filter(
(subscription: Subscription) => subscriptionIds.includes(subscription.serviceId)
);
this.logger.log(
`Found ${relatedSubscriptions.length} subscriptions for invoice ${invoiceId}`,
{
userId,
invoiceId,
subscriptionIds,
}
);
return relatedSubscriptions;
} catch (error) {
this.logger.error(`Failed to get subscriptions for invoice ${invoiceId}`, {
error: getErrorMessage(error),
userId,
invoiceId,
});
if (error instanceof NotFoundException) {
throw error;
}
throw new InternalServerErrorException("Failed to retrieve invoice subscriptions");
}
async getCancelledInvoices(
userId: string,
options: Pick<GetInvoicesOptions, "page" | "limit"> = {}
): Promise<InvoiceList> {
return this.orchestrator.getCancelledInvoices(userId, options);
}
/**
* Invalidate invoice cache for a user
* Get invoices in collections for a user
*/
async invalidateCache(userId: string, invoiceId?: number): Promise<void> {
try {
if (invoiceId) {
await this.whmcsService.invalidateInvoiceCache(userId, invoiceId);
} else {
await this.whmcsService.invalidateUserCache(userId);
}
async getCollectionsInvoices(
userId: string,
options: Pick<GetInvoicesOptions, "page" | "limit"> = {}
): Promise<InvoiceList> {
return this.orchestrator.getCollectionsInvoices(userId, options);
}
this.logger.log(
`Invalidated invoice cache for user ${userId}${invoiceId ? `, invoice ${invoiceId}` : ""}`
);
} catch (error) {
this.logger.error(`Failed to invalidate invoice cache for user ${userId}`, {
error: getErrorMessage(error),
invoiceId,
});
/**
* Create payment link for a specific invoice
*/
async createInvoicePaymentLink(
userId: string,
invoiceId: number,
gatewayName: string,
returnUrl: string
): Promise<InvoicePaymentLink> {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new Error("WHMCS client mapping not found");
}
const ssoResult = await this.whmcsService.createPaymentSsoToken(
mapping.whmcsClientId,
invoiceId,
undefined,
gatewayName
);
return {
url: ssoResult.url,
expiresAt: ssoResult.expiresAt,
gatewayName,
};
}
/**
* Get payment methods for a user
*/
async getPaymentMethods(userId: string): Promise<PaymentMethodList> {
try {
this.logger.log(`Starting payment methods retrieval for user ${userId}`);
// Get WHMCS client ID from user mapping
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
this.logger.error(`No WHMCS client mapping found for user ${userId}`);
throw new NotFoundException("WHMCS client mapping not found");
}
this.logger.log(`Found WHMCS client ID ${mapping.whmcsClientId} for user ${userId}`);
// Fetch payment methods from WHMCS
const paymentMethods = await this.whmcsService.getPaymentMethods(
mapping.whmcsClientId,
userId
);
this.logger.log(
`Retrieved ${paymentMethods.paymentMethods.length} payment methods for user ${userId} (client ${mapping.whmcsClientId})`
);
return paymentMethods;
} catch (error) {
this.logger.error(`Failed to get payment methods for user ${userId}`, {
error: getErrorMessage(error),
errorType: error instanceof Error ? error.constructor.name : typeof error,
errorMessage: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
});
if (error instanceof NotFoundException) {
throw error;
}
throw new InternalServerErrorException("Failed to retrieve payment methods");
}
}
/**
* Invalidate payment methods cache for a user
*/
async invalidatePaymentMethodsCache(userId: string): Promise<void> {
try {
// Get WHMCS client ID from user mapping
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new NotFoundException("WHMCS client mapping not found");
}
// Invalidate WHMCS payment methods cache
await this.whmcsService.invalidatePaymentMethodsCache(userId);
this.logger.log(`Invalidated payment methods cache for user ${userId}`);
} catch (error) {
this.logger.error(`Failed to invalidate payment methods cache for user ${userId}`, {
error: getErrorMessage(error),
});
throw new InternalServerErrorException("Failed to invalidate payment methods cache");
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new Error("WHMCS client mapping not found");
}
return this.whmcsService.getPaymentMethods(mapping.whmcsClientId, userId);
}
/**
* Get available payment gateways
*/
async getPaymentGateways(): Promise<PaymentGatewayList> {
try {
// Fetch payment gateways from WHMCS
const paymentGateways = await this.whmcsService.getPaymentGateways();
this.logger.log(`Retrieved ${paymentGateways.gateways.length} payment gateways`);
return paymentGateways;
} catch (error) {
this.logger.error("Failed to get payment gateways", {
error: getErrorMessage(error),
});
throw new InternalServerErrorException("Failed to retrieve payment gateways");
}
return this.whmcsService.getPaymentGateways();
}
/**
* Create payment SSO link for invoice with specific payment method or gateway
* Invalidate payment methods cache for a user
*/
async createPaymentSsoLink(
userId: string,
invoiceId: number,
paymentMethodId?: number,
gatewayName?: string
): Promise<InvoicePaymentLink> {
try {
// Validate invoice ID
if (!invoiceId || invoiceId < 1) {
throw new BadRequestException("Invalid invoice ID");
}
// Get WHMCS client ID from user mapping
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new NotFoundException("WHMCS client mapping not found");
}
// Verify the invoice exists and belongs to this user
await this.getInvoiceById(userId, invoiceId);
// Create payment SSO token with specific payment method or gateway
const ssoResult = await this.whmcsService.createPaymentSsoToken(
mapping.whmcsClientId,
invoiceId,
paymentMethodId,
gatewayName
);
const result: InvoicePaymentLink = {
url: ssoResult.url,
expiresAt: ssoResult.expiresAt,
paymentMethodId,
gatewayName,
};
this.logger.log(`Created payment SSO link for invoice ${invoiceId}, user ${userId}`, {
paymentMethodId,
gatewayName,
expiresAt: result.expiresAt,
});
return result;
} catch (error) {
this.logger.error(
`Failed to create payment SSO link for invoice ${invoiceId}, user ${userId}`,
{
error: getErrorMessage(error),
paymentMethodId,
gatewayName,
}
);
if (error instanceof NotFoundException) {
throw error;
}
throw new InternalServerErrorException("Failed to create payment SSO link");
}
async invalidatePaymentMethodsCache(userId: string): Promise<void> {
return this.whmcsService.invalidatePaymentMethodsCache(userId);
}
/**
* Health check for invoice service
*/
async healthCheck(): Promise<{ status: string; details: unknown }> {
try {
const whmcsHealthy = await this.whmcsService.healthCheck();
const health = await this.orchestrator.healthCheck();
return {
status: health.status,
details: health.details,
};
}
return {
status: whmcsHealthy ? "healthy" : "unhealthy",
details: {
whmcsApi: whmcsHealthy ? "connected" : "disconnected",
timestamp: new Date().toISOString(),
},
};
} catch (error) {
this.logger.error("Invoice service health check failed", {
error: getErrorMessage(error),
});
return {
status: "unhealthy",
details: {
error: getErrorMessage(error),
timestamp: new Date().toISOString(),
},
};
}
/**
* Get service statistics
*/
getServiceStats() {
return this.orchestrator.getServiceStats();
}
/**
* Check if user has any invoices
*/
async hasInvoices(userId: string): Promise<boolean> {
return this.orchestrator.hasInvoices(userId);
}
/**
* Get invoice count by status
*/
async getInvoiceCountByStatus(
userId: string,
status: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections"
): Promise<number> {
return this.orchestrator.getInvoiceCountByStatus(userId, status);
}
}

View File

@ -0,0 +1,193 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type { InvoiceHealthStatus, InvoiceServiceStats } from "../types/invoice-service.types";
/**
* Service responsible for health checks and monitoring of invoice services
*/
@Injectable()
export class InvoiceHealthService {
private stats: InvoiceServiceStats = {
totalInvoicesRetrieved: 0,
totalPaymentLinksCreated: 0,
totalSsoLinksCreated: 0,
averageResponseTime: 0,
};
constructor(
private readonly whmcsService: WhmcsService,
private readonly mappingsService: MappingsService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Perform comprehensive health check
*/
async healthCheck(): Promise<InvoiceHealthStatus> {
try {
const checks = await Promise.allSettled([
this.checkWhmcsHealth(),
this.checkMappingsHealth(),
]);
const whmcsResult = checks[0];
const mappingsResult = checks[1];
const isHealthy =
whmcsResult.status === "fulfilled" && whmcsResult.value &&
mappingsResult.status === "fulfilled" && mappingsResult.value;
return {
status: isHealthy ? "healthy" : "unhealthy",
details: {
whmcsApi: whmcsResult.status === "fulfilled" && whmcsResult.value ? "connected" : "disconnected",
mappingsService: mappingsResult.status === "fulfilled" && mappingsResult.value ? "available" : "unavailable",
timestamp: new Date().toISOString(),
},
};
} catch (error) {
this.logger.error("Invoice service health check failed", {
error: getErrorMessage(error),
});
return {
status: "unhealthy",
details: {
error: getErrorMessage(error),
timestamp: new Date().toISOString(),
},
};
}
}
/**
* Get service statistics
*/
getStats(): InvoiceServiceStats {
return { ...this.stats };
}
/**
* Reset service statistics
*/
resetStats(): void {
this.stats = {
totalInvoicesRetrieved: 0,
totalPaymentLinksCreated: 0,
totalSsoLinksCreated: 0,
averageResponseTime: 0,
};
}
/**
* Record invoice retrieval
*/
recordInvoiceRetrieval(responseTime: number): void {
this.stats.totalInvoicesRetrieved++;
this.updateAverageResponseTime(responseTime);
this.stats.lastRequestTime = new Date();
}
/**
* Record payment link creation
*/
recordPaymentLinkCreation(responseTime: number): void {
this.stats.totalPaymentLinksCreated++;
this.updateAverageResponseTime(responseTime);
this.stats.lastRequestTime = new Date();
}
/**
* Record SSO link creation
*/
recordSsoLinkCreation(responseTime: number): void {
this.stats.totalSsoLinksCreated++;
this.updateAverageResponseTime(responseTime);
this.stats.lastRequestTime = new Date();
}
/**
* Record error
*/
recordError(): void {
this.stats.lastErrorTime = new Date();
}
/**
* Check WHMCS service health
*/
private async checkWhmcsHealth(): Promise<boolean> {
try {
return await this.whmcsService.healthCheck();
} catch (error) {
this.logger.warn("WHMCS health check failed", {
error: getErrorMessage(error),
});
return false;
}
}
/**
* Check mappings service health
*/
private async checkMappingsHealth(): Promise<boolean> {
try {
// Simple check to see if mappings service is responsive
// We don't want to create test data, so we'll just check if the service responds
await this.mappingsService.findByUserId("health-check-test");
return true;
} catch (error) {
// We expect this to fail for a non-existent user, but if the service responds, it's healthy
const errorMessage = getErrorMessage(error);
// If it's a "not found" error, the service is working
if (errorMessage.toLowerCase().includes("not found")) {
return true;
}
this.logger.warn("Mappings service health check failed", {
error: errorMessage,
});
return false;
}
}
/**
* Update average response time
*/
private updateAverageResponseTime(responseTime: number): void {
const totalRequests =
this.stats.totalInvoicesRetrieved +
this.stats.totalPaymentLinksCreated +
this.stats.totalSsoLinksCreated;
if (totalRequests === 1) {
this.stats.averageResponseTime = responseTime;
} else {
this.stats.averageResponseTime =
(this.stats.averageResponseTime * (totalRequests - 1) + responseTime) / totalRequests;
}
}
/**
* Get health summary
*/
async getHealthSummary(): Promise<{
status: string;
uptime: number;
stats: InvoiceServiceStats;
lastCheck: string;
}> {
const health = await this.healthCheck();
return {
status: health.status,
uptime: process.uptime(),
stats: this.getStats(),
lastCheck: new Date().toISOString(),
};
}
}

View File

@ -0,0 +1,221 @@
import { Injectable, NotFoundException, InternalServerErrorException, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { Invoice, InvoiceList } from "@customer-portal/domain";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { InvoiceValidatorService } from "../validators/invoice-validator.service";
import type {
GetInvoicesOptions,
InvoiceStatus,
PaginationOptions,
UserMappingInfo
} from "../types/invoice-service.types";
/**
* Service responsible for retrieving invoices from WHMCS
*/
@Injectable()
export class InvoiceRetrievalService {
constructor(
private readonly whmcsService: WhmcsService,
private readonly mappingsService: MappingsService,
private readonly validator: InvoiceValidatorService,
@Inject(Logger) private readonly logger: Logger
) {}
/**
* Get paginated invoices for a user
*/
async getInvoices(userId: string, options: GetInvoicesOptions = {}): Promise<InvoiceList> {
const { page = 1, limit = 10, status } = options;
try {
// Validate inputs
this.validator.validateUserId(userId);
this.validator.validatePagination({ page, limit });
if (status) {
this.validator.validateInvoiceStatus(status);
}
// Get user mapping
const mapping = await this.getUserMapping(userId);
// Fetch invoices from WHMCS
const invoiceList = await this.whmcsService.getInvoices(mapping.whmcsClientId, userId, {
page,
limit,
status,
});
this.logger.log(`Retrieved ${invoiceList.invoices.length} invoices for user ${userId}`, {
page,
limit,
status,
totalItems: invoiceList.pagination?.totalItems,
});
return invoiceList;
} catch (error) {
this.logger.error(`Failed to get invoices for user ${userId}`, {
error: getErrorMessage(error),
options,
});
if (error instanceof NotFoundException) {
throw error;
}
throw new InternalServerErrorException("Failed to retrieve invoices");
}
}
/**
* Get individual invoice by ID
*/
async getInvoiceById(userId: string, invoiceId: number): Promise<Invoice> {
try {
// Validate inputs
this.validator.validateUserId(userId);
this.validator.validateInvoiceId(invoiceId);
// Get user mapping
const mapping = await this.getUserMapping(userId);
// Fetch invoice from WHMCS
const invoice = await this.whmcsService.getInvoiceById(
mapping.whmcsClientId,
userId,
invoiceId
);
this.logger.log(`Retrieved invoice ${invoiceId} for user ${userId}`);
return invoice;
} catch (error) {
this.logger.error(`Failed to get invoice ${invoiceId} for user ${userId}`, {
error: getErrorMessage(error),
});
if (error instanceof NotFoundException) {
throw error;
}
throw new InternalServerErrorException("Failed to retrieve invoice");
}
}
/**
* Get invoices by status
*/
async getInvoicesByStatus(
userId: string,
status: InvoiceStatus,
options: PaginationOptions = {}
): Promise<InvoiceList> {
const { page = 1, limit = 10 } = options;
try {
// Validate inputs
this.validator.validateUserId(userId);
this.validator.validateInvoiceStatus(status);
this.validator.validatePagination({ page, limit });
return await this.getInvoices(userId, { page, limit, status });
} catch (error) {
this.logger.error(`Failed to get ${status} invoices for user ${userId}`, {
error: getErrorMessage(error),
options,
});
if (error instanceof NotFoundException) {
throw error;
}
throw new InternalServerErrorException("Failed to retrieve invoices");
}
}
/**
* Get unpaid invoices for a user
*/
async getUnpaidInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Unpaid", options);
}
/**
* Get overdue invoices for a user
*/
async getOverdueInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Overdue", options);
}
/**
* Get paid invoices for a user
*/
async getPaidInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Paid", options);
}
/**
* Get cancelled invoices for a user
*/
async getCancelledInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Cancelled", options);
}
/**
* Get invoices in collections for a user
*/
async getCollectionsInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Collections", options);
}
/**
* Get user mapping with validation
*/
private async getUserMapping(userId: string): Promise<UserMappingInfo> {
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new NotFoundException("WHMCS client mapping not found");
}
this.validator.validateWhmcsClientId(mapping.whmcsClientId);
return {
userId,
whmcsClientId: mapping.whmcsClientId,
};
}
/**
* Check if user has any invoices
*/
async hasInvoices(userId: string): Promise<boolean> {
try {
const invoices = await this.getInvoices(userId, { page: 1, limit: 1 });
return invoices.invoices.length > 0;
} catch (error) {
this.logger.warn(`Failed to check if user ${userId} has invoices`, {
error: getErrorMessage(error),
});
return false;
}
}
/**
* Get invoice count by status
*/
async getInvoiceCountByStatus(userId: string, status: InvoiceStatus): Promise<number> {
try {
const invoices = await this.getInvoicesByStatus(userId, status, { page: 1, limit: 1 });
return invoices.pagination?.totalItems || 0;
} catch (error) {
this.logger.warn(`Failed to get ${status} invoice count for user ${userId}`, {
error: getErrorMessage(error),
});
return 0;
}
}
}

View File

@ -0,0 +1,206 @@
import { Injectable, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import {
Invoice,
InvoiceList,
InvoiceSsoLink,
InvoicePaymentLink,
PaymentMethodList,
PaymentGatewayList
} from "@customer-portal/domain";
import { InvoiceRetrievalService } from "./invoice-retrieval.service";
import { InvoiceHealthService } from "./invoice-health.service";
import { InvoiceValidatorService } from "../validators/invoice-validator.service";
import type {
GetInvoicesOptions,
InvoiceStatus,
PaginationOptions,
InvoiceHealthStatus,
InvoiceServiceStats
} from "../types/invoice-service.types";
/**
* Main orchestrator service for invoice operations
* Coordinates all invoice-related services and provides a unified interface
*/
@Injectable()
export class InvoicesOrchestratorService {
constructor(
private readonly retrievalService: InvoiceRetrievalService,
private readonly healthService: InvoiceHealthService,
private readonly validator: InvoiceValidatorService,
@Inject(Logger) private readonly logger: Logger
) {}
// ==========================================
// INVOICE RETRIEVAL METHODS
// ==========================================
/**
* Get paginated invoices for a user
*/
async getInvoices(userId: string, options: GetInvoicesOptions = {}): Promise<InvoiceList> {
const startTime = Date.now();
try {
const result = await this.retrievalService.getInvoices(userId, options);
this.healthService.recordInvoiceRetrieval(Date.now() - startTime);
return result;
} catch (error) {
this.healthService.recordError();
throw error;
}
}
/**
* Get individual invoice by ID
*/
async getInvoiceById(userId: string, invoiceId: number): Promise<Invoice> {
const startTime = Date.now();
try {
const result = await this.retrievalService.getInvoiceById(userId, invoiceId);
this.healthService.recordInvoiceRetrieval(Date.now() - startTime);
return result;
} catch (error) {
this.healthService.recordError();
throw error;
}
}
/**
* Get invoices by status
*/
async getInvoicesByStatus(
userId: string,
status: InvoiceStatus,
options: PaginationOptions = {}
): Promise<InvoiceList> {
const startTime = Date.now();
try {
const result = await this.retrievalService.getInvoicesByStatus(userId, status, options);
this.healthService.recordInvoiceRetrieval(Date.now() - startTime);
return result;
} catch (error) {
this.healthService.recordError();
throw error;
}
}
/**
* Get unpaid invoices for a user
*/
async getUnpaidInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
return this.retrievalService.getUnpaidInvoices(userId, options);
}
/**
* Get overdue invoices for a user
*/
async getOverdueInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
return this.retrievalService.getOverdueInvoices(userId, options);
}
/**
* Get paid invoices for a user
*/
async getPaidInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
return this.retrievalService.getPaidInvoices(userId, options);
}
/**
* Get cancelled invoices for a user
*/
async getCancelledInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
return this.retrievalService.getCancelledInvoices(userId, options);
}
/**
* Get invoices in collections for a user
*/
async getCollectionsInvoices(userId: string, options: PaginationOptions = {}): Promise<InvoiceList> {
return this.retrievalService.getCollectionsInvoices(userId, options);
}
// ==========================================
// INVOICE OPERATIONS METHODS
// ==========================================
// ==========================================
// UTILITY METHODS
// ==========================================
/**
* Check if user has any invoices
*/
async hasInvoices(userId: string): Promise<boolean> {
return this.retrievalService.hasInvoices(userId);
}
/**
* Get invoice count by status
*/
async getInvoiceCountByStatus(userId: string, status: InvoiceStatus): Promise<number> {
return this.retrievalService.getInvoiceCountByStatus(userId, status);
}
/**
* Health check for invoice service
*/
async healthCheck(): Promise<InvoiceHealthStatus> {
return this.healthService.healthCheck();
}
/**
* Get service statistics
*/
getServiceStats(): InvoiceServiceStats {
return this.healthService.getStats();
}
/**
* Reset service statistics
*/
resetServiceStats(): void {
this.healthService.resetStats();
}
/**
* Get health summary
*/
async getHealthSummary(): Promise<{
status: string;
uptime: number;
stats: InvoiceServiceStats;
lastCheck: string;
}> {
return this.healthService.getHealthSummary();
}
/**
* Validate get invoices options
*/
validateGetInvoicesOptions(options: GetInvoicesOptions) {
return this.validator.validateGetInvoicesOptions(options);
}
/**
* Get valid invoice statuses
*/
getValidStatuses() {
return this.validator.getValidStatuses();
}
/**
* Get pagination limits
*/
getPaginationLimits() {
return this.validator.getPaginationLimits();
}
}

View File

@ -0,0 +1,41 @@
export interface GetInvoicesOptions {
page?: number;
limit?: number;
status?: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections";
}
export interface InvoiceValidationResult {
isValid: boolean;
errors: string[];
}
export interface InvoiceServiceStats {
totalInvoicesRetrieved: number;
totalPaymentLinksCreated: number;
totalSsoLinksCreated: number;
averageResponseTime: number;
lastRequestTime?: Date;
lastErrorTime?: Date;
}
export interface InvoiceHealthStatus {
status: "healthy" | "unhealthy";
details: {
whmcsApi?: string;
mappingsService?: string;
error?: string;
timestamp: string;
};
}
export type InvoiceStatus = "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections";
export interface PaginationOptions {
page?: number;
limit?: number;
}
export interface UserMappingInfo {
userId: string;
whmcsClientId: number;
}

View File

@ -0,0 +1,164 @@
import { Injectable, BadRequestException } from "@nestjs/common";
import type {
GetInvoicesOptions,
InvoiceValidationResult,
InvoiceStatus,
PaginationOptions
} from "../types/invoice-service.types";
/**
* Service for validating invoice-related inputs and business rules
*/
@Injectable()
export class InvoiceValidatorService {
private readonly validStatuses: readonly InvoiceStatus[] = [
"Paid", "Unpaid", "Cancelled", "Overdue", "Collections"
] as const;
private readonly maxLimit = 100;
private readonly minLimit = 1;
/**
* Validate invoice ID
*/
validateInvoiceId(invoiceId: number): void {
if (!invoiceId || invoiceId < 1) {
throw new BadRequestException("Invalid invoice ID");
}
}
/**
* Validate user ID
*/
validateUserId(userId: string): void {
if (!userId || typeof userId !== "string" || userId.trim().length === 0) {
throw new BadRequestException("Invalid user ID");
}
}
/**
* Validate pagination parameters
*/
validatePagination(options: PaginationOptions): void {
const { page = 1, limit = 10 } = options;
if (page < 1) {
throw new BadRequestException("Page must be greater than 0");
}
if (limit < this.minLimit || limit > this.maxLimit) {
throw new BadRequestException(`Limit must be between ${this.minLimit} and ${this.maxLimit}`);
}
}
/**
* Validate invoice status
*/
validateInvoiceStatus(status: string): InvoiceStatus {
if (!this.validStatuses.includes(status as InvoiceStatus)) {
throw new BadRequestException(
`Invalid status. Must be one of: ${this.validStatuses.join(", ")}`
);
}
return status as InvoiceStatus;
}
/**
* Validate get invoices options
*/
validateGetInvoicesOptions(options: GetInvoicesOptions): InvoiceValidationResult {
const errors: string[] = [];
try {
this.validatePagination(options);
} catch (error) {
if (error instanceof BadRequestException) {
errors.push(error.message);
}
}
if (options.status) {
try {
this.validateInvoiceStatus(options.status);
} catch (error) {
if (error instanceof BadRequestException) {
errors.push(error.message);
}
}
}
return {
isValid: errors.length === 0,
errors,
};
}
/**
* Validate WHMCS client ID
*/
validateWhmcsClientId(clientId: number | undefined): void {
if (!clientId || clientId < 1) {
throw new BadRequestException("Invalid WHMCS client ID");
}
}
/**
* Validate payment gateway name
*/
validatePaymentGateway(gatewayName: string): void {
if (!gatewayName || typeof gatewayName !== "string" || gatewayName.trim().length === 0) {
throw new BadRequestException("Invalid payment gateway name");
}
}
/**
* Validate return URL for payment links
*/
validateReturnUrl(returnUrl: string): void {
if (!returnUrl || typeof returnUrl !== "string") {
throw new BadRequestException("Return URL is required");
}
try {
new URL(returnUrl);
} catch {
throw new BadRequestException("Invalid return URL format");
}
}
/**
* Get valid invoice statuses
*/
getValidStatuses(): readonly InvoiceStatus[] {
return this.validStatuses;
}
/**
* Get pagination limits
*/
getPaginationLimits(): { min: number; max: number } {
return {
min: this.minLimit,
max: this.maxLimit,
};
}
/**
* Sanitize pagination options with defaults
*/
sanitizePaginationOptions(options: PaginationOptions): Required<PaginationOptions> {
const { page = 1, limit = 10 } = options;
return {
page: Math.max(1, Math.floor(page)),
limit: Math.max(this.minLimit, Math.min(this.maxLimit, Math.floor(limit))),
};
}
/**
* Check if status is a valid invoice status
*/
isValidStatus(status: string): status is InvoiceStatus {
return this.validStatuses.includes(status as InvoiceStatus);
}
}

View File

@ -1,4 +1,4 @@
import { AuthLayout } from "@/components/templates/AuthLayout";
import { AuthLayout } from "@/components/templates/AuthLayout/AuthLayout";
import { Skeleton } from "@/components/atoms/loading-skeleton";
export default function AuthSegmentLoading() {

View File

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

View File

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

View File

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

View File

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

View File

@ -1,5 +1,5 @@
import { CheckCircleIcon } from "@heroicons/react/24/outline";
import { AnimatedCard } from "@/components/molecules/AnimatedCard";
import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard";
interface Step {
number: number;

View File

@ -13,10 +13,10 @@ export type { FormFieldProps } from "./FormField/FormField";
export { SearchFilterBar } from "./SearchFilterBar/SearchFilterBar";
export type { SearchFilterBarProps, FilterOption } from "./SearchFilterBar/SearchFilterBar";
export * from "./PaginationBar";
export * from "./DetailHeader";
export * from "./AlertBanner";
export * from "./AsyncBlock";
export * from "./PaginationBar/PaginationBar";
export * from "./DetailHeader/DetailHeader";
export * from "./AlertBanner/AlertBanner";
export * from "./AsyncBlock/AsyncBlock";
export * from "./SectionHeader/SectionHeader";
export * from "./ProgressSteps/ProgressSteps";
export * from "./SubCard/SubCard";

View File

@ -2,15 +2,15 @@
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { UserIcon, PencilIcon, CheckIcon, XMarkIcon } from "@heroicons/react/24/outline";
import type { ProfileEditFormData } from "@customer-portal/domain";
import type { ProfileDisplayData } from "@customer-portal/domain";
interface PersonalInfoCardProps {
data: ProfileEditFormData;
data: ProfileDisplayData;
isEditing: boolean;
isSaving: boolean;
onEdit: () => void;
onCancel: () => void;
onChange: (field: keyof ProfileEditFormData, value: string) => void;
onChange: (field: keyof ProfileDisplayData, value: string) => void;
onSave: () => void;
}

View File

@ -2,7 +2,7 @@
import { useEffect, useState } from "react";
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
import { AlertBanner } from "@/components/molecules/AlertBanner";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { MapPinIcon, PencilIcon, CheckIcon, XMarkIcon, UserIcon } from "@heroicons/react/24/outline";
import { useAuthStore } from "@/features/auth/services/auth.store";
import { accountService } from "@/features/account/services/account.service";

View File

@ -1 +0,0 @@
export { LinkWhmcsForm } from "./LinkWhmcsForm";

View File

@ -1 +0,0 @@
export { LoginForm } from "./LoginForm";

View File

@ -1 +0,0 @@
export { PasswordResetForm } from "./PasswordResetForm";

View File

@ -1 +0,0 @@
export { SetPasswordForm } from "./SetPasswordForm";

View File

@ -3,9 +3,9 @@
* Centralized exports for authentication components
*/
export { LoginForm } from "./LoginForm";
export { SignupForm } from "./SignupForm";
export { PasswordResetForm } from "./PasswordResetForm";
export { SetPasswordForm } from "./SetPasswordForm";
export { LinkWhmcsForm } from "./LinkWhmcsForm";
export { LoginForm } from "./LoginForm/LoginForm";
export { SignupForm } from "./SignupForm/SignupForm";
export { PasswordResetForm } from "./PasswordResetForm/PasswordResetForm";
export { SetPasswordForm } from "./SetPasswordForm/SetPasswordForm";
export { LinkWhmcsForm } from "./LinkWhmcsForm/LinkWhmcsForm";
export { AuthLayout } from "@/components/templates/AuthLayout";

View File

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

View File

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

View File

@ -1,7 +1,7 @@
"use client";
import React from "react";
import { DetailHeader } from "@/components/molecules/DetailHeader";
import { DetailHeader } from "@/components/molecules/DetailHeader/DetailHeader";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import {
ArrowTopRightOnSquareIcon,

View File

@ -3,9 +3,9 @@
import React, { useMemo, useState } from "react";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { LoadingTable } from "@/components/atoms/loading-skeleton";
import { AsyncBlock } from "@/components/molecules/AsyncBlock";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
import { PaginationBar } from "@/components/molecules/PaginationBar";
import { PaginationBar } from "@/components/molecules/PaginationBar/PaginationBar";
import { InvoiceTable } from "@/features/billing/components/InvoiceTable/InvoiceTable";
import { useInvoices } from "@/features/billing/hooks/useBilling";
import { useSubscriptionInvoices } from "@/features/subscriptions/hooks/useSubscriptions";

View File

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

View File

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

View File

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

View File

@ -18,7 +18,7 @@ import {
InvoiceItems,
InvoiceTotals,
} from "@/features/billing/components/InvoiceDetail";
import { AlertBanner } from "@/components/molecules/AlertBanner";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { InvoicePaymentActions } from "@/features/billing/components/InvoiceDetail/InvoicePaymentActions";
export function InvoiceDetailContainer() {

View File

@ -15,7 +15,7 @@ import { CreditCardIcon, PlusIcon } from "@heroicons/react/24/outline";
import { InlineToast } from "@/components/atoms/inline-toast";
import { SectionHeader } from "@/components/molecules";
import { Button } from "@/components/atoms/button";
import { AsyncBlock } from "@/components/molecules/AsyncBlock";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
import { logger } from "@customer-portal/logging";
import { EmptyState } from "@/components/atoms/empty-state";

View File

@ -5,7 +5,7 @@ import type { CatalogProductBase } from "@customer-portal/domain";
import { getMonthlyPrice, getOneTimePrice } from "../../utils/pricing";
interface AddonGroupProps {
addons: Array<CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean; raw?: any }>;
addons: Array<CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean; raw?: unknown }>;
selectedAddonSkus: string[];
onAddonToggle: (skus: string[]) => void;
showSkus?: boolean;
@ -23,7 +23,7 @@ type BundledAddonGroup = {
};
function buildGroupedAddons(
addons: Array<CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean; raw?: any }>
addons: Array<CatalogProductBase & { bundledAddonId?: string; isBundledAddon?: boolean; raw?: unknown }>
): BundledAddonGroup[] {
const groups: BundledAddonGroup[] = [];
const processedSkus = new Set<string>();
@ -34,7 +34,12 @@ function buildGroupedAddons(
if (processedSkus.has(addon.sku)) return;
if (addon.isBundledAddon && addon.bundledAddonId) {
const partner = sorted.find(candidate => candidate.raw.Id === addon.bundledAddonId);
const partner = sorted.find(candidate =>
candidate.raw &&
typeof candidate.raw === 'object' &&
'Id' in candidate.raw &&
candidate.raw.Id === addon.bundledAddonId
);
if (partner) {
const monthlyAddon = addon.billingCycle === "Monthly" ? addon : partner;

View File

@ -1,7 +1,7 @@
"use client";
import { Skeleton } from "@/components/atoms";
import { AlertBanner } from "@/components/molecules/AlertBanner";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { Button } from "@/components/atoms/button";
import { SubCard } from "@/components/molecules/SubCard/SubCard";

View File

@ -7,7 +7,7 @@ import {
ExclamationTriangleIcon,
InformationCircleIcon,
} from "@heroicons/react/24/outline";
import { AnimatedCard } from "@/components/molecules/AnimatedCard";
import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard";
import { Button } from "@/components/atoms/button";
export interface StepValidation {

View File

@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import { Skeleton } from "@/components/atoms/loading-skeleton";
import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { CreditCardIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import type { PaymentMethod } from "@customer-portal/domain";

View File

@ -2,7 +2,7 @@
import { ReactNode } from "react";
import { CheckIcon, XMarkIcon, CurrencyYenIcon } from "@heroicons/react/24/outline";
import { AnimatedCard } from "@/components/molecules/AnimatedCard";
import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard";
import { Button } from "@/components/atoms/button";
export interface ComparisonProduct {

View File

@ -1,7 +1,7 @@
"use client";
import React from "react";
import { AnimatedCard } from "@/components/molecules/AnimatedCard";
import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard";
export function FeatureCard({
icon,

View File

@ -1,7 +1,7 @@
"use client";
import React from "react";
import { AnimatedCard } from "@/components/molecules/AnimatedCard";
import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard";
import { Button } from "@/components/atoms/button";
import { ArrowRightIcon } from "@heroicons/react/24/outline";

View File

@ -1,2 +0,0 @@
export * from "./ServiceHeroCard";
export * from "./FeatureCard";

View File

@ -3,7 +3,7 @@
import { AnimatedCard } from "@/components/molecules";
import { Button } from "@/components/atoms/button";
import { StepHeader } from "@/components/atoms";
import { AlertBanner } from "@/components/molecules/AlertBanner";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { ArrowRightIcon } from "@heroicons/react/24/outline";
import type { InternetPlanCatalogItem } from "@customer-portal/domain";
import type { AccessMode } from "../../../hooks/useConfigureParams";

View File

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

View File

@ -1,7 +1,7 @@
"use client";
import { DevicePhoneMobileIcon, UsersIcon, CurrencyYenIcon } from "@heroicons/react/24/outline";
import { AnimatedCard } from "@/components/molecules/AnimatedCard";
import { AnimatedCard } from "@/components/molecules/AnimatedCard/AnimatedCard";
import { Button } from "@/components/atoms/button";
import type { SimCatalogProduct } from "@customer-portal/domain";
import { getMonthlyPrice } from "../../utils/pricing";

View File

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

View File

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

View File

@ -21,10 +21,10 @@ import type {
import { getMonthlyPrice } from "../utils/pricing";
import { LoadingCard, Skeleton, LoadingTable } from "@/components/atoms/loading-skeleton";
import { AnimatedCard } from "@/components/molecules";
import { AsyncBlock } from "@/components/molecules/AsyncBlock";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { Button } from "@/components/atoms/button";
import { InternetPlanCard } from "@/features/catalog/components/internet/InternetPlanCard";
import { AlertBanner } from "@/components/molecules/AlertBanner";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
export function InternetPlansContainer() {
const { data, isLoading, error } = useInternetCatalog();

View File

@ -13,7 +13,7 @@ import {
} from "@heroicons/react/24/outline";
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { useSimCatalog } from "@/features/catalog/hooks";
import type { SimCatalogProduct } from "@customer-portal/domain";
import { SimPlanTypeSection } from "@/features/catalog/components/sim/SimPlanTypeSection";

View File

@ -4,9 +4,9 @@ import { PageLayout } from "@/components/templates/PageLayout";
import { ShieldCheckIcon, ArrowLeftIcon } from "@heroicons/react/24/outline";
import { useVpnCatalog } from "@/features/catalog/hooks";
import { LoadingCard } from "@/components/atoms";
import { AsyncBlock } from "@/components/molecules/AsyncBlock";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { VpnPlanCard } from "@/features/catalog/components/vpn/VpnPlanCard";
export function VpnPlansView() {

View File

@ -3,8 +3,8 @@ import { useCheckout } from "@/features/checkout/hooks/useCheckout";
import { PageLayout } from "@/components/templates/PageLayout";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { Button } from "@/components/atoms/button";
import { AlertBanner } from "@/components/molecules/AlertBanner";
import { PageAsync } from "@/components/molecules/AsyncBlock";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { PageAsync } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { InlineToast } from "@/components/atoms/inline-toast";
import { StatusPill } from "@/components/atoms/status-pill";
import { AddressConfirmation } from "@/features/catalog/components/base/AddressConfirmation";

View File

@ -1,6 +1,6 @@
"use client";
import { AlertBanner } from "@/components/molecules/AlertBanner";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
interface PaymentErrorBannerProps {
message: string;

View File

@ -1,4 +0,0 @@
export { OrderCard } from "./OrderCard";
export { OrderCardSkeleton } from "./OrderCardSkeleton";

View File

@ -5,7 +5,7 @@ import { useRouter, useSearchParams } from "next/navigation";
import { PageLayout } from "@/components/templates/PageLayout";
import { ClipboardDocumentListIcon, CheckCircleIcon } from "@heroicons/react/24/outline";
import { AnimatedCard } from "@/components/molecules";
import { AlertBanner } from "@/components/molecules/AlertBanner";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { ordersService } from "@/features/orders/services/orders.service";
import { OrderCard } from "@/features/orders/components/OrderCard";
import { OrderCardSkeleton } from "@/features/orders/components/OrderCardSkeleton";

View File

@ -7,7 +7,7 @@ import { PageLayout } from "@/components/templates/PageLayout";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
import { AlertBanner } from "@/components/molecules/AlertBanner";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
const PLAN_CODES = ["PASI_5G", "PASI_10G", "PASI_25G", "PASI_50G"] as const;
type PlanCode = (typeof PLAN_CODES)[number];

View File

@ -6,7 +6,7 @@ import { useParams } from "next/navigation";
import { PageLayout } from "@/components/templates/PageLayout";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { simActionsService } from "@/features/subscriptions/services/sim-actions.service";
import { AlertBanner } from "@/components/molecules/AlertBanner";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { DevicePhoneMobileIcon } from "@heroicons/react/24/outline";
export function SimTopUpContainer() {

View File

@ -2,7 +2,7 @@
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton";
import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { DetailHeader } from "@/components/molecules/DetailHeader";
import { DetailHeader } from "@/components/molecules/DetailHeader/DetailHeader";
import { useEffect, useState } from "react";
import { useParams, useSearchParams } from "next/navigation";
import Link from "next/link";

View File

@ -12,7 +12,7 @@ import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
import { LoadingTable } from "@/components/atoms/loading-skeleton";
import { ErrorState } from "@/components/atoms/error-state";
import { AsyncBlock } from "@/components/molecules/AsyncBlock";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import {
ServerIcon,
CheckCircleIcon,

View File

@ -17,6 +17,11 @@ export const profileEditFormSchema = updateProfileRequestSchema.extend({
lastName: nameSchema,
});
// Profile display schema includes non-editable fields like email
export const profileDisplaySchema = profileEditFormSchema.extend({
email: emailSchema, // Read-only field for display
});
// Use required address schema for forms where address is mandatory
export const addressFormSchema = requiredAddressSchema;
@ -65,8 +70,12 @@ import type {
UpdateAddressRequest as UpdateAddressRequestData,
} from "../api/requests";
// Import email schema for display type
import { emailSchema } from "../shared/primitives";
// Export form types and API request types
export type ProfileEditFormData = z.infer<typeof profileEditFormSchema>;
export type ProfileDisplayData = z.infer<typeof profileDisplaySchema>;
export type AddressFormData = z.infer<typeof addressFormSchema>;
export type ContactFormData = z.infer<typeof contactFormSchema>;

View File

@ -16,6 +16,9 @@
// Shared validation modules (modular architecture)
export * from "./shared";
// Export specific billing cycle types for convenience
export type { SubscriptionBillingCycleSchema as SubscriptionBillingCycle } from "./shared/primitives";
// API request schemas (backend) - explicit exports for better tree shaking
export {
// Auth API schemas
@ -109,11 +112,13 @@ export {
export {
// Profile form schemas
profileEditFormSchema,
profileDisplaySchema,
addressFormSchema,
contactFormSchema,
// Profile form types
type ProfileEditFormData,
type ProfileDisplayData,
type AddressFormData,
type ContactFormData,
@ -152,5 +157,19 @@ export {
type PaymentMethodValidation,
} from "./business";
// Order validation schemas and types
export {
orderItemProductSchema,
orderDetailItemSchema,
orderSummaryItemSchema,
orderDetailsSchema,
orderSummarySchema,
type OrderItemProduct,
type OrderDetailItem,
type OrderItemSummary,
type OrderDetailsResponse,
type OrderSummaryResponse,
} from "./shared/order";
// Simple validation utilities (direct Zod usage)
export { z, parseOrThrow, safeParse } from "./shared/utilities";

View File

@ -96,6 +96,10 @@ export const statusEnum = z.enum(["active", "inactive", "pending", "suspended"])
export const priorityEnum = z.enum(["low", "medium", "high", "urgent"]);
export const categoryEnum = z.enum(["technical", "billing", "account", "general"]);
// Billing cycle enums
export const billingCycleEnum = z.enum(["Monthly", "Quarterly", "Annually", "Onetime", "Free"]);
export const subscriptionBillingCycleEnum = z.enum(["Monthly", "Quarterly", "Annually", "Biennially", "Triennially"]);
// =====================================================
// TYPE EXPORTS
// =====================================================
@ -115,3 +119,5 @@ export type GenderSchema = z.infer<typeof genderEnum>;
export type StatusSchema = z.infer<typeof statusEnum>;
export type PrioritySchema = z.infer<typeof priorityEnum>;
export type CategorySchema = z.infer<typeof categoryEnum>;
export type BillingCycleSchema = z.infer<typeof billingCycleEnum>;
export type SubscriptionBillingCycleSchema = z.infer<typeof subscriptionBillingCycleEnum>;