Refactor Billing Controller to Improve Error Handling
- Replaced inline error throwing with NotFoundException in the BillingController to enhance error handling and provide clearer responses for missing WHMCS client mappings. - Updated multiple methods to ensure consistent error management across the controller, improving maintainability and clarity in API responses.
This commit is contained in:
parent
465a62a3e8
commit
934a87330d
@ -1,10 +1,11 @@
|
||||
/**
|
||||
* User DB Mapper
|
||||
*
|
||||
* Adapts @prisma/client User to domain UserAuth type
|
||||
* Adapts @prisma/client User to domain UserAuth type.
|
||||
* This thin adapter exists because domain cannot import @prisma/client directly.
|
||||
*
|
||||
* NOTE: This is an infrastructure adapter - Prisma is BFF's ORM implementation detail.
|
||||
* The domain provider handles the actual mapping logic.
|
||||
* NOTE: UserAuth contains ONLY auth state. Profile data comes from WHMCS.
|
||||
* For complete user profile, use UsersFacade.getProfile() which fetches from WHMCS.
|
||||
*/
|
||||
|
||||
import type { User as PrismaUser } from "@prisma/client";
|
||||
@ -15,29 +16,8 @@ type PrismaUserRaw = Parameters<typeof mapPrismaUserToUserAuth>[0];
|
||||
|
||||
/**
|
||||
* Maps Prisma User entity to Domain UserAuth type
|
||||
*
|
||||
* This adapter converts the @prisma/client User to the domain's PrismaUserRaw type,
|
||||
* then uses the domain portal provider mapper to get UserAuth.
|
||||
*
|
||||
* NOTE: UserAuth contains ONLY auth state. Profile data comes from WHMCS.
|
||||
* For complete user profile, use UsersFacade.getProfile() which fetches from WHMCS.
|
||||
*/
|
||||
export function mapPrismaUserToDomain(user: PrismaUser): UserAuth {
|
||||
// Convert @prisma/client User to domain PrismaUserRaw
|
||||
const prismaUserRaw: PrismaUserRaw = {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
passwordHash: user.passwordHash,
|
||||
role: user.role,
|
||||
mfaSecret: user.mfaSecret,
|
||||
emailVerified: user.emailVerified,
|
||||
failedLoginAttempts: user.failedLoginAttempts,
|
||||
lockedUntil: user.lockedUntil,
|
||||
lastLoginAt: user.lastLoginAt,
|
||||
createdAt: user.createdAt,
|
||||
updatedAt: user.updatedAt,
|
||||
};
|
||||
|
||||
// Use domain provider mapper
|
||||
return mapPrismaUserToUserAuth(prismaUserRaw);
|
||||
// PrismaUser and PrismaUserRaw are structurally identical - cast and delegate
|
||||
return mapPrismaUserToUserAuth(user as PrismaUserRaw);
|
||||
}
|
||||
|
||||
@ -25,9 +25,9 @@ import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
||||
import { TokenBlacklistService } from "../infra/token/token-blacklist.service.js";
|
||||
import { AuthTokenService } from "../infra/token/token.service.js";
|
||||
import { AuthRateLimitService } from "../infra/rate-limiting/auth-rate-limit.service.js";
|
||||
import { SignupWorkflowService } from "../infra/workflows/workflows/signup-workflow.service.js";
|
||||
import { PasswordWorkflowService } from "../infra/workflows/workflows/password-workflow.service.js";
|
||||
import { WhmcsLinkWorkflowService } from "../infra/workflows/workflows/whmcs-link-workflow.service.js";
|
||||
import { SignupWorkflowService } from "../infra/workflows/signup-workflow.service.js";
|
||||
import { PasswordWorkflowService } from "../infra/workflows/password-workflow.service.js";
|
||||
import { WhmcsLinkWorkflowService } from "../infra/workflows/whmcs-link-workflow.service.js";
|
||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers/index.js";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@ -11,9 +11,9 @@ import { EmailModule } from "@bff/infra/email/email.module.js";
|
||||
import { CacheModule } from "@bff/infra/cache/cache.module.js";
|
||||
import { AuthTokenService } from "./infra/token/token.service.js";
|
||||
import { JoseJwtService } from "./infra/token/jose-jwt.service.js";
|
||||
import { SignupWorkflowService } from "./infra/workflows/workflows/signup-workflow.service.js";
|
||||
import { PasswordWorkflowService } from "./infra/workflows/workflows/password-workflow.service.js";
|
||||
import { WhmcsLinkWorkflowService } from "./infra/workflows/workflows/whmcs-link-workflow.service.js";
|
||||
import { SignupWorkflowService } from "./infra/workflows/signup-workflow.service.js";
|
||||
import { PasswordWorkflowService } from "./infra/workflows/password-workflow.service.js";
|
||||
import { WhmcsLinkWorkflowService } from "./infra/workflows/whmcs-link-workflow.service.js";
|
||||
import { FailedLoginThrottleGuard } from "./presentation/http/guards/failed-login-throttle.guard.js";
|
||||
import { LoginResultInterceptor } from "./presentation/http/interceptors/login-result.interceptor.js";
|
||||
import { AuthRateLimitService } from "./infra/rate-limiting/auth-rate-limit.service.js";
|
||||
|
||||
@ -13,9 +13,9 @@ import { UsersFacade } from "@bff/modules/users/application/users.facade.js";
|
||||
import { AuditService, AuditAction } from "@bff/infra/audit/audit.service.js";
|
||||
import { EmailService } from "@bff/infra/email/email.service.js";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { AuthTokenService } from "../../token/token.service.js";
|
||||
import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service.js";
|
||||
import { JoseJwtService } from "../../token/jose-jwt.service.js";
|
||||
import { AuthTokenService } from "../token/token.service.js";
|
||||
import { AuthRateLimitService } from "../rate-limiting/auth-rate-limit.service.js";
|
||||
import { JoseJwtService } from "../token/jose-jwt.service.js";
|
||||
import {
|
||||
type ChangePasswordRequest,
|
||||
changePasswordRequestSchema,
|
||||
@ -17,8 +17,8 @@ import { WhmcsAccountDiscoveryService } from "@bff/integrations/whmcs/services/w
|
||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
|
||||
import { SalesforceAccountService } from "@bff/integrations/salesforce/services/salesforce-account.service.js";
|
||||
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
||||
import { AuthTokenService } from "../../token/token.service.js";
|
||||
import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service.js";
|
||||
import { AuthTokenService } from "../token/token.service.js";
|
||||
import { AuthRateLimitService } from "../rate-limiting/auth-rate-limit.service.js";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import { DomainHttpException } from "@bff/core/http/domain-http.exception.js";
|
||||
import {
|
||||
@ -1,5 +1,15 @@
|
||||
import { Controller, Get, Post, Param, Query, Request, HttpCode, HttpStatus } from "@nestjs/common";
|
||||
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service.js";
|
||||
import {
|
||||
Controller,
|
||||
Get,
|
||||
Post,
|
||||
Param,
|
||||
Query,
|
||||
Request,
|
||||
HttpCode,
|
||||
HttpStatus,
|
||||
NotFoundException,
|
||||
} from "@nestjs/common";
|
||||
import { InvoiceRetrievalService } from "./services/invoice-retrieval.service.js";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { createZodDto, ZodResponse } from "nestjs-zod";
|
||||
@ -15,7 +25,6 @@ import {
|
||||
invoiceSsoQuerySchema,
|
||||
invoicePaymentLinkQuerySchema,
|
||||
} from "@customer-portal/domain/billing";
|
||||
import type { Subscription } from "@customer-portal/domain/subscriptions";
|
||||
import type {
|
||||
PaymentMethodList,
|
||||
PaymentGatewayList,
|
||||
@ -47,7 +56,7 @@ class InvoicePaymentLinkDto extends createZodDto(invoicePaymentLinkSchema) {}
|
||||
@Controller("invoices")
|
||||
export class BillingController {
|
||||
constructor(
|
||||
private readonly invoicesService: InvoicesOrchestratorService,
|
||||
private readonly invoicesService: InvoiceRetrievalService,
|
||||
private readonly whmcsService: WhmcsService,
|
||||
private readonly mappingsService: MappingsService
|
||||
) {}
|
||||
@ -66,7 +75,7 @@ export class BillingController {
|
||||
async getPaymentMethods(@Request() req: RequestWithUser): Promise<PaymentMethodList> {
|
||||
const mapping = await this.mappingsService.findByUserId(req.user.id);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
throw new Error("WHMCS client mapping not found");
|
||||
throw new NotFoundException("WHMCS client mapping not found");
|
||||
}
|
||||
return this.whmcsService.getPaymentMethods(mapping.whmcsClientId, req.user.id);
|
||||
}
|
||||
@ -87,7 +96,7 @@ export class BillingController {
|
||||
// Return fresh payment methods
|
||||
const mapping = await this.mappingsService.findByUserId(req.user.id);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
throw new Error("WHMCS client mapping not found");
|
||||
throw new NotFoundException("WHMCS client mapping not found");
|
||||
}
|
||||
return this.whmcsService.getPaymentMethods(mapping.whmcsClientId, req.user.id);
|
||||
}
|
||||
@ -101,13 +110,6 @@ export class BillingController {
|
||||
return this.invoicesService.getInvoiceById(req.user.id, params.id);
|
||||
}
|
||||
|
||||
@Get(":id/subscriptions")
|
||||
getInvoiceSubscriptions(): Subscription[] {
|
||||
// This functionality has been moved to WHMCS directly
|
||||
// For now, return empty array as subscriptions are managed in WHMCS
|
||||
return [];
|
||||
}
|
||||
|
||||
@Post(":id/sso-link")
|
||||
@HttpCode(HttpStatus.OK)
|
||||
@ZodResponse({ description: "Create invoice SSO link", type: InvoiceSsoLinkDto })
|
||||
@ -118,7 +120,7 @@ export class BillingController {
|
||||
): Promise<InvoiceSsoLink> {
|
||||
const mapping = await this.mappingsService.findByUserId(req.user.id);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
throw new Error("WHMCS client mapping not found");
|
||||
throw new NotFoundException("WHMCS client mapping not found");
|
||||
}
|
||||
|
||||
const parsedQuery = invoiceSsoQuerySchema.parse(query as unknown);
|
||||
@ -145,7 +147,7 @@ export class BillingController {
|
||||
): Promise<InvoicePaymentLink> {
|
||||
const mapping = await this.mappingsService.findByUserId(req.user.id);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
throw new Error("WHMCS client mapping not found");
|
||||
throw new NotFoundException("WHMCS client mapping not found");
|
||||
}
|
||||
|
||||
const parsedQuery = invoicePaymentLinkQuerySchema.parse(query as unknown);
|
||||
|
||||
@ -2,21 +2,17 @@ import { Module } from "@nestjs/common";
|
||||
import { BillingController } from "./billing.controller.js";
|
||||
import { WhmcsModule } from "@bff/integrations/whmcs/whmcs.module.js";
|
||||
import { MappingsModule } from "@bff/modules/id-mappings/mappings.module.js";
|
||||
// New modular invoice services
|
||||
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service.js";
|
||||
import { InvoiceRetrievalService } from "./services/invoice-retrieval.service.js";
|
||||
import { InvoiceHealthService } from "./services/invoice-health.service.js";
|
||||
|
||||
/**
|
||||
* Billing Module
|
||||
*
|
||||
* Validation is handled by Zod schemas via Zod DTOs + the global ZodValidationPipe (APP_PIPE).
|
||||
* No separate validator service needed.
|
||||
*/
|
||||
@Module({
|
||||
imports: [WhmcsModule, MappingsModule],
|
||||
controllers: [BillingController],
|
||||
providers: [InvoicesOrchestratorService, InvoiceRetrievalService, InvoiceHealthService],
|
||||
exports: [InvoicesOrchestratorService],
|
||||
providers: [InvoiceRetrievalService],
|
||||
exports: [InvoiceRetrievalService],
|
||||
})
|
||||
export class BillingModule {}
|
||||
|
||||
@ -4,9 +4,4 @@
|
||||
|
||||
export * from "./billing.module.js";
|
||||
export * from "./billing.controller.js";
|
||||
export * from "./services/invoices-orchestrator.service.js";
|
||||
export * from "./services/invoice-retrieval.service.js";
|
||||
export * from "./services/invoice-health.service.js";
|
||||
|
||||
// Export monitoring types (infrastructure concerns)
|
||||
export type { InvoiceHealthStatus, InvoiceServiceStats } from "./types/invoice-monitoring.types.js";
|
||||
|
||||
@ -1,202 +0,0 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
|
||||
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
||||
import type {
|
||||
InvoiceHealthStatus,
|
||||
InvoiceServiceStats,
|
||||
} from "../types/invoice-monitoring.types.js";
|
||||
|
||||
/**
|
||||
* 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(),
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -5,7 +5,6 @@ import {
|
||||
Inject,
|
||||
} from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { invoiceSchema, invoiceListQuerySchema } from "@customer-portal/domain/billing";
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceList,
|
||||
@ -33,13 +32,9 @@ export class InvoiceRetrievalService {
|
||||
|
||||
/**
|
||||
* Get paginated invoices for a user
|
||||
* @param userId - User ID (should be validated by controller)
|
||||
* @param options - Query options (should be validated by controller using invoiceListQuerySchema)
|
||||
*/
|
||||
async getInvoices(userId: string, options: InvoiceListQuery = {}): Promise<InvoiceList> {
|
||||
// Validate options against schema for internal calls
|
||||
const validatedOptions = invoiceListQuerySchema.parse(options);
|
||||
const { page = 1, limit = 10, status } = validatedOptions;
|
||||
const { page = 1, limit = 10, status } = options;
|
||||
|
||||
try {
|
||||
// Get user mapping
|
||||
@ -76,15 +71,9 @@ export class InvoiceRetrievalService {
|
||||
|
||||
/**
|
||||
* Get individual invoice by ID
|
||||
* @param userId - User ID (should be validated by controller)
|
||||
* @param invoiceId - Invoice ID (should be validated by controller/schema)
|
||||
*/
|
||||
async getInvoiceById(userId: string, invoiceId: number): Promise<Invoice> {
|
||||
try {
|
||||
// Validate invoice ID using schema
|
||||
invoiceSchema.shape.id.parse(invoiceId);
|
||||
|
||||
// Get user mapping
|
||||
const mapping = await this.getUserMapping(userId);
|
||||
|
||||
// Fetch invoice from WHMCS
|
||||
|
||||
@ -1,206 +0,0 @@
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { INVOICE_PAGINATION, VALID_INVOICE_STATUSES } from "@customer-portal/domain/billing";
|
||||
import type {
|
||||
Invoice,
|
||||
InvoiceList,
|
||||
InvoiceListQuery,
|
||||
InvoiceStatus,
|
||||
} from "@customer-portal/domain/billing";
|
||||
import { InvoiceRetrievalService } from "./invoice-retrieval.service.js";
|
||||
import { InvoiceHealthService } from "./invoice-health.service.js";
|
||||
import type {
|
||||
InvoiceHealthStatus,
|
||||
InvoiceServiceStats,
|
||||
} from "../types/invoice-monitoring.types.js";
|
||||
|
||||
/**
|
||||
* 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,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
// ==========================================
|
||||
// INVOICE RETRIEVAL METHODS
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Get paginated invoices for a user
|
||||
*/
|
||||
async getInvoices(userId: string, options: InvoiceListQuery = {}): 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: Partial<InvoiceListQuery> = {}
|
||||
): 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: Partial<InvoiceListQuery> = {}
|
||||
): Promise<InvoiceList> {
|
||||
return this.retrievalService.getUnpaidInvoices(userId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get overdue invoices for a user
|
||||
*/
|
||||
async getOverdueInvoices(
|
||||
userId: string,
|
||||
options: Partial<InvoiceListQuery> = {}
|
||||
): Promise<InvoiceList> {
|
||||
return this.retrievalService.getOverdueInvoices(userId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get paid invoices for a user
|
||||
*/
|
||||
async getPaidInvoices(
|
||||
userId: string,
|
||||
options: Partial<InvoiceListQuery> = {}
|
||||
): Promise<InvoiceList> {
|
||||
return this.retrievalService.getPaidInvoices(userId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cancelled invoices for a user
|
||||
*/
|
||||
async getCancelledInvoices(
|
||||
userId: string,
|
||||
options: Partial<InvoiceListQuery> = {}
|
||||
): Promise<InvoiceList> {
|
||||
return this.retrievalService.getCancelledInvoices(userId, options);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get invoices in collections for a user
|
||||
*/
|
||||
async getCollectionsInvoices(
|
||||
userId: string,
|
||||
options: Partial<InvoiceListQuery> = {}
|
||||
): 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();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get valid invoice statuses (from domain)
|
||||
*/
|
||||
getValidStatuses(): readonly InvoiceStatus[] {
|
||||
return VALID_INVOICE_STATUSES;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pagination limits (from domain)
|
||||
*/
|
||||
getPaginationLimits(): { min: number; max: number } {
|
||||
return {
|
||||
min: INVOICE_PAGINATION.MIN_LIMIT,
|
||||
max: INVOICE_PAGINATION.MAX_LIMIT,
|
||||
};
|
||||
}
|
||||
}
|
||||
@ -1,26 +0,0 @@
|
||||
/**
|
||||
* BFF Invoice Monitoring Types
|
||||
*
|
||||
* Infrastructure types for monitoring, health checks, and statistics.
|
||||
* These are BFF-specific and do not belong in the domain layer.
|
||||
*/
|
||||
|
||||
// Infrastructure monitoring types
|
||||
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;
|
||||
};
|
||||
}
|
||||
@ -0,0 +1,84 @@
|
||||
/**
|
||||
* WHMCS Custom Field Utilities (domain-internal)
|
||||
*/
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
|
||||
const normalizeCustomFieldEntries = (value: unknown): Array<Record<string, unknown>> => {
|
||||
if (Array.isArray(value)) return value.filter(isObject);
|
||||
if (isObject(value) && "customfield" in value) {
|
||||
const custom = (value as { customfield?: unknown }).customfield;
|
||||
if (Array.isArray(custom)) return custom.filter(isObject);
|
||||
if (isObject(custom)) return [custom];
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a lightweight map of WHMCS custom field identifiers to values.
|
||||
* Accepts the documented WHMCS response shapes (array or { customfield }).
|
||||
*/
|
||||
export function getCustomFieldsMap(customFields: unknown): Record<string, string> {
|
||||
if (!customFields) return {};
|
||||
|
||||
if (isObject(customFields) && !Array.isArray(customFields) && !("customfield" in customFields)) {
|
||||
return Object.entries(customFields).reduce<Record<string, string>>((acc, [key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
const trimmedKey = key.trim();
|
||||
if (trimmedKey) acc[trimmedKey] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
const map: Record<string, string> = {};
|
||||
for (const entry of normalizeCustomFieldEntries(customFields)) {
|
||||
const idRaw = "id" in entry ? entry.id : undefined;
|
||||
const id =
|
||||
typeof idRaw === "string"
|
||||
? idRaw.trim()
|
||||
: typeof idRaw === "number"
|
||||
? String(idRaw)
|
||||
: undefined;
|
||||
const name = "name" in entry && typeof entry.name === "string" ? entry.name.trim() : undefined;
|
||||
const rawValue = "value" in entry ? entry.value : undefined;
|
||||
if (rawValue === undefined || rawValue === null) continue;
|
||||
const value =
|
||||
typeof rawValue === "string"
|
||||
? rawValue
|
||||
: typeof rawValue === "number" || typeof rawValue === "boolean"
|
||||
? String(rawValue)
|
||||
: undefined;
|
||||
if (!value) continue;
|
||||
|
||||
if (id) map[id] = value;
|
||||
if (name) map[name] = value;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a custom field value by numeric id or name.
|
||||
*/
|
||||
export function getCustomFieldValue(
|
||||
customFields: unknown,
|
||||
key: string | number
|
||||
): string | undefined {
|
||||
if (key === undefined || key === null) return undefined;
|
||||
const map = getCustomFieldsMap(customFields);
|
||||
const primary = map[String(key)];
|
||||
if (primary !== undefined) return primary;
|
||||
|
||||
if (typeof key === "string") {
|
||||
const numeric = Number.parseInt(key, 10);
|
||||
if (!Number.isNaN(numeric)) {
|
||||
const numericValue = map[String(numeric)];
|
||||
if (numericValue !== undefined) return numericValue;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
79
packages/domain/common/providers/whmcs-utils/encoding.ts
Normal file
79
packages/domain/common/providers/whmcs-utils/encoding.ts
Normal file
@ -0,0 +1,79 @@
|
||||
/**
|
||||
* Encoding utilities for WHMCS helpers (domain-internal)
|
||||
*
|
||||
* Avoid hard Node `Buffer` dependency. Use it when available; otherwise fall back
|
||||
* to pure JS UTF-8 + base64.
|
||||
*/
|
||||
|
||||
const BASE64_ALPHABET = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
function encodeUtf8Fallback(value: string): Uint8Array {
|
||||
const bytes: number[] = [];
|
||||
for (let i = 0; i < value.length; i++) {
|
||||
let codePoint = value.charCodeAt(i);
|
||||
|
||||
// Handle surrogate pairs
|
||||
if (codePoint >= 0xd800 && codePoint <= 0xdbff && i + 1 < value.length) {
|
||||
const next = value.charCodeAt(i + 1);
|
||||
if (next >= 0xdc00 && next <= 0xdfff) {
|
||||
codePoint = ((codePoint - 0xd800) << 10) + (next - 0xdc00) + 0x10000;
|
||||
i++;
|
||||
}
|
||||
}
|
||||
|
||||
if (codePoint <= 0x7f) {
|
||||
bytes.push(codePoint);
|
||||
} else if (codePoint <= 0x7ff) {
|
||||
bytes.push(0xc0 | (codePoint >> 6));
|
||||
bytes.push(0x80 | (codePoint & 0x3f));
|
||||
} else if (codePoint <= 0xffff) {
|
||||
bytes.push(0xe0 | (codePoint >> 12));
|
||||
bytes.push(0x80 | ((codePoint >> 6) & 0x3f));
|
||||
bytes.push(0x80 | (codePoint & 0x3f));
|
||||
} else {
|
||||
bytes.push(0xf0 | (codePoint >> 18));
|
||||
bytes.push(0x80 | ((codePoint >> 12) & 0x3f));
|
||||
bytes.push(0x80 | ((codePoint >> 6) & 0x3f));
|
||||
bytes.push(0x80 | (codePoint & 0x3f));
|
||||
}
|
||||
}
|
||||
return new Uint8Array(bytes);
|
||||
}
|
||||
|
||||
function encodeUtf8(value: string): Uint8Array {
|
||||
if (typeof TextEncoder !== "undefined") {
|
||||
return new TextEncoder().encode(value);
|
||||
}
|
||||
return encodeUtf8Fallback(value);
|
||||
}
|
||||
|
||||
export function byteLengthUtf8(value: string): number {
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return Buffer.byteLength(value, "utf8");
|
||||
}
|
||||
return encodeUtf8(value).length;
|
||||
}
|
||||
|
||||
function base64EncodeBytes(bytes: Uint8Array): string {
|
||||
let out = "";
|
||||
for (let i = 0; i < bytes.length; i += 3) {
|
||||
const b1 = bytes[i] ?? 0;
|
||||
const b2 = bytes[i + 1] ?? 0;
|
||||
const b3 = bytes[i + 2] ?? 0;
|
||||
|
||||
const triplet = (b1 << 16) | (b2 << 8) | b3;
|
||||
|
||||
out += BASE64_ALPHABET[(triplet >> 18) & 0x3f];
|
||||
out += BASE64_ALPHABET[(triplet >> 12) & 0x3f];
|
||||
out += i + 1 < bytes.length ? BASE64_ALPHABET[(triplet >> 6) & 0x3f] : "=";
|
||||
out += i + 2 < bytes.length ? BASE64_ALPHABET[triplet & 0x3f] : "=";
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
export function utf8ToBase64(value: string): string {
|
||||
if (typeof Buffer !== "undefined") {
|
||||
return Buffer.from(value, "utf8").toString("base64");
|
||||
}
|
||||
return base64EncodeBytes(encodeUtf8(value));
|
||||
}
|
||||
12
packages/domain/common/providers/whmcs-utils/index.ts
Normal file
12
packages/domain/common/providers/whmcs-utils/index.ts
Normal file
@ -0,0 +1,12 @@
|
||||
/**
|
||||
* WHMCS shared provider helpers (domain-internal)
|
||||
*
|
||||
* Intentionally NOT exported from `@customer-portal/domain/common/providers` to avoid
|
||||
* expanding the app-facing API surface. This module is used by domain mappers via
|
||||
* an internal alias.
|
||||
*/
|
||||
|
||||
export * from "./parsing.js";
|
||||
export * from "./normalize.js";
|
||||
export * from "./custom-fields.js";
|
||||
export * from "./php-serialize.js";
|
||||
34
packages/domain/common/providers/whmcs-utils/normalize.ts
Normal file
34
packages/domain/common/providers/whmcs-utils/normalize.ts
Normal file
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* WHMCS Normalization Utilities (domain-internal)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Normalize status using provided status map.
|
||||
* Generic helper for consistent status mapping.
|
||||
*/
|
||||
export function normalizeStatus<T extends string>(
|
||||
status: string | null | undefined,
|
||||
statusMap: Record<string, T>,
|
||||
defaultStatus: T
|
||||
): T {
|
||||
if (!status) return defaultStatus;
|
||||
const mapped = statusMap[status.trim().toLowerCase()];
|
||||
return mapped ?? defaultStatus;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize billing cycle using provided cycle map.
|
||||
* Generic helper for consistent cycle mapping.
|
||||
*/
|
||||
export function normalizeCycle<T extends string>(
|
||||
cycle: string | null | undefined,
|
||||
cycleMap: Record<string, T>,
|
||||
defaultCycle: T
|
||||
): T {
|
||||
if (!cycle) return defaultCycle;
|
||||
const normalized = cycle
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[_\s-]+/g, " ");
|
||||
return cycleMap[normalized] ?? defaultCycle;
|
||||
}
|
||||
29
packages/domain/common/providers/whmcs-utils/parsing.ts
Normal file
29
packages/domain/common/providers/whmcs-utils/parsing.ts
Normal file
@ -0,0 +1,29 @@
|
||||
/**
|
||||
* WHMCS Parsing Utilities (domain-internal)
|
||||
*/
|
||||
|
||||
/**
|
||||
* Parse amount from WHMCS API response.
|
||||
* WHMCS returns amounts as strings or numbers.
|
||||
*/
|
||||
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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Format date from WHMCS API to ISO string.
|
||||
* Returns undefined if input is invalid.
|
||||
*/
|
||||
export function formatDate(input?: string | null): string | undefined {
|
||||
if (!input) return undefined;
|
||||
|
||||
const date = new Date(input);
|
||||
if (Number.isNaN(date.getTime())) return undefined;
|
||||
|
||||
return date.toISOString();
|
||||
}
|
||||
@ -0,0 +1,34 @@
|
||||
/**
|
||||
* WHMCS PHP Serialization Utilities (domain-internal)
|
||||
*/
|
||||
|
||||
import { byteLengthUtf8, utf8ToBase64 } from "./encoding.js";
|
||||
|
||||
/**
|
||||
* Serialize a key/value map into the format WHMCS expects for request parameters like `customfields`.
|
||||
*
|
||||
* Official docs:
|
||||
* - AddClient: customfields = "Base64 encoded serialized array of custom field values."
|
||||
* @see https://developers.whmcs.com/api-reference/addclient/
|
||||
*/
|
||||
export function serializeWhmcsKeyValueMap(data?: Record<string, string>): string {
|
||||
if (!data) return "";
|
||||
const entries = Object.entries(data).filter(([k]) => String(k).trim().length > 0);
|
||||
if (entries.length === 0) return "";
|
||||
|
||||
const serializedEntries = entries.map(([key, value]) => {
|
||||
const safeKey = key ?? "";
|
||||
const safeValue = value ?? "";
|
||||
return (
|
||||
`s:${byteLengthUtf8(safeKey)}:"${escapePhpString(safeKey)}";` +
|
||||
`s:${byteLengthUtf8(safeValue)}:"${escapePhpString(safeValue)}";`
|
||||
);
|
||||
});
|
||||
|
||||
const serialized = `a:${serializedEntries.length}:{${serializedEntries.join("")}}`;
|
||||
return utf8ToBase64(serialized);
|
||||
}
|
||||
|
||||
function escapePhpString(value: string): string {
|
||||
return value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user