481 lines
15 KiB
TypeScript
481 lines
15 KiB
TypeScript
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, Providers } from "@customer-portal/domain/billing";
|
|
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
|
import { WhmcsCurrencyService } from "./whmcs-currency.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 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 = 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 = 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 = Providers.Whmcs.transformWhmcsInvoice(response, {
|
|
defaultCurrencyCode: defaultCurrency.code,
|
|
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
|
|
});
|
|
|
|
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<void> {
|
|
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 {
|
|
// Transform using domain mapper
|
|
const defaultCurrency = this.currencyService.getDefaultCurrency();
|
|
const transformed = Providers.Whmcs.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 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.";
|
|
}
|
|
}
|