- Added optional userId parameter to payment capture methods in WhmcsService and WhmcsInvoiceService to improve tracking and management of user-related transactions. - Updated invoice retrieval and user profile services to utilize parseUuidOrThrow for user ID validation, ensuring consistent error messaging for invalid formats. - Refactored SIM billing and activation services to include userId in one-time charge creation, enhancing billing traceability. - Adjusted validation logic in various services to improve clarity and maintainability, ensuring robust handling of user IDs throughout the application.
506 lines
16 KiB
TypeScript
506 lines
16 KiB
TypeScript
import { getErrorMessage } from "@bff/core/utils/error.util.js";
|
|
import { Logger } from "nestjs-pino";
|
|
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
|
import { WhmcsOperationException } from "@bff/core/exceptions/domain-exceptions.js";
|
|
import { invoiceListSchema, invoiceSchema } from "@customer-portal/domain/billing";
|
|
import type { Invoice, InvoiceList } from "@customer-portal/domain/billing";
|
|
import { transformWhmcsInvoice } from "@customer-portal/domain/billing/providers";
|
|
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service.js";
|
|
import { WhmcsCurrencyService } from "./whmcs-currency.service.js";
|
|
import { WhmcsCacheService } from "../cache/whmcs-cache.service.js";
|
|
import type {
|
|
WhmcsGetInvoicesParams,
|
|
WhmcsCreateInvoiceParams,
|
|
WhmcsUpdateInvoiceParams,
|
|
WhmcsCapturePaymentParams,
|
|
} from "@customer-portal/domain/billing/providers";
|
|
import type {
|
|
WhmcsInvoiceListResponse,
|
|
WhmcsInvoiceResponse,
|
|
WhmcsCreateInvoiceResponse,
|
|
WhmcsUpdateInvoiceResponse,
|
|
WhmcsCapturePaymentResponse,
|
|
} from "@customer-portal/domain/billing/providers";
|
|
|
|
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 currencyService: WhmcsCurrencyService,
|
|
private readonly cacheService: WhmcsCacheService
|
|
) {}
|
|
|
|
/**
|
|
* Get paginated invoices for a client with caching
|
|
*/
|
|
async getInvoices(
|
|
clientId: number,
|
|
userId: string,
|
|
filters: InvoiceFilters = {}
|
|
): Promise<InvoiceList> {
|
|
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: WhmcsInvoiceListResponse = 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<InvoiceList> {
|
|
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<Invoice> {
|
|
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: WhmcsInvoiceResponse = 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 using domain mapper
|
|
const defaultCurrency = this.currencyService.getDefaultCurrency();
|
|
const invoice = transformWhmcsInvoice(response, {
|
|
defaultCurrencyCode: defaultCurrency.code,
|
|
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
|
|
});
|
|
|
|
const parseResult = invoiceSchema.safeParse(invoice);
|
|
if (!parseResult.success) {
|
|
throw new WhmcsOperationException("Invalid invoice data after transformation", {
|
|
invoiceId: invoice.id,
|
|
validationErrors: parseResult.error.issues,
|
|
});
|
|
}
|
|
|
|
// 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<void> {
|
|
await this.cacheService.invalidateInvoice(userId, invoiceId);
|
|
this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`);
|
|
}
|
|
|
|
private transformInvoicesResponse(
|
|
response: WhmcsInvoiceListResponse,
|
|
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 invoiceRecords = response.invoices.invoice;
|
|
|
|
const invoices: Invoice[] = [];
|
|
for (const whmcsInvoice of invoiceRecords) {
|
|
try {
|
|
// Transform using domain mapper
|
|
const defaultCurrency = this.currencyService.getDefaultCurrency();
|
|
const transformed = transformWhmcsInvoice(whmcsInvoice, {
|
|
defaultCurrencyCode: defaultCurrency.code,
|
|
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
|
|
});
|
|
const parsed = invoiceSchema.parse(transformed as unknown);
|
|
invoices.push(parsed);
|
|
} catch (error) {
|
|
this.logger.error(`Failed to transform WHMCS invoice ${whmcsInvoice?.id ?? "unknown"}`, {
|
|
error: getErrorMessage(error),
|
|
rawInvoice: whmcsInvoice,
|
|
});
|
|
}
|
|
}
|
|
|
|
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: WhmcsCreateInvoiceResponse =
|
|
await this.connectionService.createInvoice(whmcsParams);
|
|
|
|
if (response.result !== "success") {
|
|
throw new WhmcsOperationException(`WHMCS invoice creation failed: ${response.message}`, {
|
|
clientId: params.clientId,
|
|
});
|
|
}
|
|
|
|
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: WhmcsUpdateInvoiceResponse =
|
|
await this.connectionService.updateInvoice(whmcsParams);
|
|
|
|
if (response.result !== "success") {
|
|
throw new WhmcsOperationException(`WHMCS invoice update failed: ${response.message}`, {
|
|
invoiceId: params.invoiceId,
|
|
});
|
|
}
|
|
|
|
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;
|
|
userId?: string;
|
|
}): Promise<{ success: boolean; transactionId?: string; error?: string }> {
|
|
try {
|
|
const whmcsParams: WhmcsCapturePaymentParams = {
|
|
invoiceid: params.invoiceId,
|
|
};
|
|
|
|
const response: WhmcsCapturePaymentResponse =
|
|
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
|
|
if (params.userId) {
|
|
await this.cacheService.invalidateInvoice(params.userId, 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.";
|
|
}
|
|
}
|