import { getErrorMessage } from "@bff/core/utils/error.util"; import { Logger } from "nestjs-pino"; import { Injectable, NotFoundException, Inject } from "@nestjs/common"; import { Invoice, InvoiceList, invoiceListSchema, invoiceSchema } from "@customer-portal/domain"; import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service"; import { InvoiceTransformerService } from "../transformers/services/invoice-transformer.service"; import { WhmcsCacheService } from "../cache/whmcs-cache.service"; import { WhmcsGetInvoicesParams, WhmcsInvoicesResponse, WhmcsCreateInvoiceParams, WhmcsUpdateInvoiceParams, WhmcsCapturePaymentParams, } from "../types/whmcs-api.types"; export type InvoiceFilters = Partial<{ status: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections"; page: number; limit: number; }>; @Injectable() export class WhmcsInvoiceService { constructor( @Inject(Logger) private readonly logger: Logger, private readonly connectionService: WhmcsConnectionOrchestratorService, private readonly invoiceTransformer: InvoiceTransformerService, private readonly cacheService: WhmcsCacheService ) {} /** * Get paginated invoices for a client with caching */ async getInvoices( clientId: number, userId: string, filters: InvoiceFilters = {} ): Promise { const { status, page = 1, limit = 10 } = filters; try { // Try cache first const cached = await this.cacheService.getInvoicesList(userId, page, limit, status); if (cached) { this.logger.debug(`Cache hit for invoices: user ${userId}, page ${page}`); return cached; } // Calculate pagination for WHMCS API const limitstart = (page - 1) * limit; // Fetch from WHMCS API const params: WhmcsGetInvoicesParams = { userid: clientId, // WHMCS API uses 'userid' parameter, not 'clientid' limitstart, limitnum: limit, orderby: "date", order: "DESC", ...(status && { status: status as WhmcsGetInvoicesParams["status"] }), }; const response = await this.connectionService.getInvoices(params); const transformed = this.transformInvoicesResponse(response, clientId, page, limit); const result = invoiceListSchema.parse(transformed as unknown); // Cache the result await this.cacheService.setInvoicesList(userId, page, limit, status, result); this.logger.log( `Fetched ${result.invoices.length} invoices for client ${clientId}, page ${page}` ); return result; } catch (error) { this.logger.error(`Failed to fetch invoices for client ${clientId}`, { error: getErrorMessage(error), filters, }); throw error; } } /** * Get invoices with items (for subscription linking) * This method fetches invoices and then enriches them with item details */ async getInvoicesWithItems( clientId: number, userId: string, filters: InvoiceFilters = {} ): Promise { try { // First get the basic invoices list const invoiceList = await this.getInvoices(clientId, userId, filters); // For each invoice, fetch the detailed version with items const invoicesWithItems = await Promise.all( invoiceList.invoices.map(async (invoice: Invoice) => { try { // Get detailed invoice with items const detailedInvoice = await this.getInvoiceById(clientId, userId, invoice.id); return invoiceSchema.parse(detailedInvoice); } catch (error) { this.logger.warn( `Failed to fetch details for invoice ${invoice.id}`, getErrorMessage(error) ); // Return the basic invoice if detailed fetch fails return invoice; } }) ); const result: InvoiceList = { invoices: invoicesWithItems, pagination: invoiceList.pagination, }; this.logger.log( `Fetched ${invoicesWithItems.length} invoices with items for client ${clientId}` ); return result; } catch (error) { this.logger.error(`Failed to fetch invoices with items for client ${clientId}`, { error: getErrorMessage(error), filters, }); throw error; } } /** * Get individual invoice by ID with caching */ async getInvoiceById(clientId: number, userId: string, invoiceId: number): Promise { try { // Try cache first const cached = await this.cacheService.getInvoice(userId, invoiceId); if (cached) { this.logger.debug(`Cache hit for invoice: user ${userId}, invoice ${invoiceId}`); return cached; } // Fetch from WHMCS API const response = await this.connectionService.getInvoice(invoiceId); if (!response.invoiceid) { throw new NotFoundException(`Invoice ${invoiceId} not found`); } // Verify the invoice belongs to this client const invoiceClientId = response.userid; if (invoiceClientId !== clientId) { throw new NotFoundException(`Invoice ${invoiceId} not found`); } // Transform invoice const invoice = this.invoiceTransformer.transformInvoice(response); const parseResult = invoiceSchema.safeParse(invoice); if (!parseResult.success) { throw new Error(`Invalid invoice data after transformation`); } // Cache the result await this.cacheService.setInvoice(userId, invoiceId, invoice); this.logger.log(`Fetched invoice ${invoiceId} for client ${clientId}`); return invoice; } catch (error) { this.logger.error(`Failed to fetch invoice ${invoiceId} for client ${clientId}`, { error: getErrorMessage(error), }); throw error; } } /** * Invalidate cache for a specific invoice */ async invalidateInvoiceCache(userId: string, invoiceId: number): Promise { await this.cacheService.invalidateInvoice(userId, invoiceId); this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`); } private transformInvoicesResponse( response: WhmcsInvoicesResponse, clientId: number, page: number, limit: number ): InvoiceList { if (!response.invoices?.invoice) { this.logger.warn(`No invoices found for client ${clientId}`); return { invoices: [], pagination: { page, totalPages: 0, totalItems: 0, }, } satisfies InvoiceList; } const invoices: Invoice[] = []; for (const whmcsInvoice of response.invoices.invoice) { try { const transformed = this.invoiceTransformer.transformInvoice(whmcsInvoice); const parsed = invoiceSchema.parse(transformed as unknown); invoices.push(parsed); } catch (error) { this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, { error: getErrorMessage(error), }); } } this.logger.debug(`WHMCS GetInvoices Response Analysis for Client ${clientId}:`, { totalresults: response.totalresults, numreturned: response.numreturned, startnumber: response.startnumber, actualInvoicesReturned: invoices.length, requestParams: { userid: clientId, limitstart: (page - 1) * limit, limitnum: limit, orderby: "date", order: "DESC", }, }); const totalItems = response.totalresults || 0; const totalPages = Math.ceil(totalItems / limit); return { invoices, pagination: { page, totalPages, totalItems, nextCursor: page < totalPages ? (page + 1).toString() : undefined, }, } satisfies InvoiceList; } // ======================================== // Invoice Creation and Payment Methods (Used by SIM/Order services) // ======================================== /** * 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" | "Overdue"; dueDate?: Date; notes?: string; }): Promise<{ success: boolean; message?: string }> { try { let statusForUpdate: WhmcsUpdateInvoiceParams["status"]; if (params.status === "Payment Pending") { statusForUpdate = "Unpaid"; } else { statusForUpdate = params.status; } const whmcsParams: WhmcsUpdateInvoiceParams = { invoiceid: params.invoiceId, status: statusForUpdate, 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."; } }