Refactor WHMCS integration to enhance invoice management capabilities. Introduce methods for creating, updating, and capturing payments for invoices, while removing legacy transformer services. Update type definitions for invoice-related API interactions and improve error handling across invoice operations. Streamline service dependencies and ensure consistent logging for better maintainability.
This commit is contained in:
parent
065e2f9acf
commit
9fafd227b9
@ -24,7 +24,7 @@ export class FreebitClientService {
|
||||
*/
|
||||
async makeAuthenticatedRequest<
|
||||
TResponse extends FreebitResponseBase,
|
||||
TPayload extends Record<string, unknown>,
|
||||
TPayload extends object,
|
||||
>(endpoint: string, payload: TPayload): Promise<TResponse> {
|
||||
const authKey = await this.authService.getAuthKey();
|
||||
const config = this.authService.getConfig();
|
||||
|
||||
@ -10,11 +10,17 @@ import {
|
||||
statusKey as sfStatusKey,
|
||||
latestSeenKey as sfLatestSeenKey,
|
||||
} from "./event-keys.util";
|
||||
import type {
|
||||
SalesforcePubSubEvent,
|
||||
SalesforcePubSubError,
|
||||
SalesforcePubSubSubscription,
|
||||
SalesforcePubSubCallbackType,
|
||||
} from "../types/pubsub-events.types";
|
||||
|
||||
type SubscribeCallback = (
|
||||
subscription: { topicName: string },
|
||||
callbackType: string,
|
||||
data: unknown
|
||||
subscription: SalesforcePubSubSubscription,
|
||||
callbackType: SalesforcePubSubCallbackType,
|
||||
data: SalesforcePubSubEvent | SalesforcePubSubError | unknown
|
||||
) => void | Promise<void>;
|
||||
|
||||
interface PubSubClient {
|
||||
@ -110,27 +116,17 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
|
||||
const argTypes = [typeof subscription, typeof callbackType, typeof data];
|
||||
const type = callbackType;
|
||||
const typeNorm = String(type || "").toLowerCase();
|
||||
const topic = (subscription as { topicName?: string })?.topicName || this.channel;
|
||||
const topic = subscription.topicName || this.channel;
|
||||
|
||||
if (typeNorm === "data" || typeNorm === "event") {
|
||||
const event = data as Record<string, unknown>;
|
||||
const event = data as SalesforcePubSubEvent;
|
||||
// Basic breadcrumb to confirm we are handling data callbacks
|
||||
this.logger.debug("SF Pub/Sub data callback received", {
|
||||
topic,
|
||||
argTypes,
|
||||
hasPayload: ((): boolean => {
|
||||
if (!event || typeof event !== "object") return false;
|
||||
const maybePayload = event["payload"];
|
||||
return typeof maybePayload === "object" && maybePayload !== null;
|
||||
})(),
|
||||
hasPayload: Boolean(event?.payload),
|
||||
});
|
||||
const payload = ((): Record<string, unknown> | undefined => {
|
||||
const p = event["payload"];
|
||||
if (typeof p === "object" && p !== null) {
|
||||
return p as Record<string, unknown>;
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
const payload = event?.payload;
|
||||
|
||||
// Only check parsed payload
|
||||
const orderIdVal = payload?.["OrderId__c"] ?? payload?.["OrderId"];
|
||||
@ -189,14 +185,12 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
|
||||
this.logger.warn("SF Pub/Sub stream error", { topic, data });
|
||||
try {
|
||||
// Detect replay id corruption and auto-recover once by clearing the cursor and resubscribing
|
||||
const maybeObj = (data || {}) as Record<string, unknown>;
|
||||
const details = typeof maybeObj["details"] === "string" ? maybeObj["details"] : "";
|
||||
const metadata = (maybeObj["metadata"] || {}) as Record<string, unknown>;
|
||||
const errorCodes = Array.isArray((metadata as { [k: string]: unknown })["error-code"])
|
||||
? ((metadata as { [k: string]: unknown })["error-code"] as unknown[])
|
||||
: [];
|
||||
const hasCorruptionCode = errorCodes.some(v =>
|
||||
String(v).includes("replayid.corrupted")
|
||||
const errorData = data as SalesforcePubSubError;
|
||||
const details = errorData.details || "";
|
||||
const metadata = errorData.metadata || {};
|
||||
const errorCodes = Array.isArray(metadata["error-code"]) ? metadata["error-code"] : [];
|
||||
const hasCorruptionCode = errorCodes.some(code =>
|
||||
String(code).includes("replayid.corrupted")
|
||||
);
|
||||
const mentionsReplayValidation = /Replay ID validation failed/i.test(details);
|
||||
|
||||
@ -228,12 +222,8 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
|
||||
}
|
||||
} else {
|
||||
// Unknown callback type: log once with minimal context
|
||||
const maybeEvent = data as Record<string, unknown> | undefined;
|
||||
const hasPayload = ((): boolean => {
|
||||
if (!maybeEvent || typeof maybeEvent !== "object") return false;
|
||||
const p = maybeEvent["payload"];
|
||||
return typeof p === "object" && p !== null;
|
||||
})();
|
||||
const maybeEvent = data as SalesforcePubSubEvent | undefined;
|
||||
const hasPayload = Boolean(maybeEvent?.payload);
|
||||
this.logger.debug("SF Pub/Sub callback ignored (unknown type)", {
|
||||
type,
|
||||
topic,
|
||||
|
||||
@ -0,0 +1,40 @@
|
||||
/**
|
||||
* Salesforce Pub/Sub Event Types
|
||||
* Based on Salesforce Platform Event structure
|
||||
*/
|
||||
|
||||
export interface SalesforcePubSubSubscription {
|
||||
topicName: string;
|
||||
}
|
||||
|
||||
export interface SalesforcePubSubEventPayload {
|
||||
OrderId__c?: string;
|
||||
OrderId?: string;
|
||||
// Add other known fields as needed
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SalesforcePubSubEvent {
|
||||
payload: SalesforcePubSubEventPayload;
|
||||
replayId?: number;
|
||||
// Add other known event fields
|
||||
}
|
||||
|
||||
export interface SalesforcePubSubErrorMetadata {
|
||||
"error-code"?: string[];
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface SalesforcePubSubError {
|
||||
details?: string;
|
||||
metadata?: SalesforcePubSubErrorMetadata;
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type SalesforcePubSubCallbackType = "data" | "event" | "grpcstatus" | "end" | "error";
|
||||
|
||||
export interface SalesforcePubSubCallback {
|
||||
subscription: SalesforcePubSubSubscription;
|
||||
callbackType: SalesforcePubSubCallbackType;
|
||||
data: SalesforcePubSubEvent | SalesforcePubSubError | unknown;
|
||||
}
|
||||
@ -10,12 +10,18 @@ import type {
|
||||
WhmcsCatalogProductsResponse,
|
||||
WhmcsPayMethodsResponse,
|
||||
WhmcsPaymentGatewaysResponse,
|
||||
WhmcsCreateInvoiceResponse,
|
||||
WhmcsUpdateInvoiceResponse,
|
||||
WhmcsCapturePaymentResponse,
|
||||
WhmcsGetInvoicesParams,
|
||||
WhmcsGetClientsProductsParams,
|
||||
WhmcsCreateSsoTokenParams,
|
||||
WhmcsValidateLoginParams,
|
||||
WhmcsAddClientParams,
|
||||
WhmcsGetPayMethodsParams,
|
||||
WhmcsCreateInvoiceParams,
|
||||
WhmcsUpdateInvoiceParams,
|
||||
WhmcsCapturePaymentParams,
|
||||
} from "../../types/whmcs-api.types";
|
||||
import { WhmcsHttpClientService } from "./whmcs-http-client.service";
|
||||
import { WhmcsConfigService } from "../config/whmcs-config.service";
|
||||
@ -136,6 +142,62 @@ export class WhmcsApiMethodsService {
|
||||
return this.makeRequest("GetPaymentMethods", {});
|
||||
}
|
||||
|
||||
async createInvoice(params: WhmcsCreateInvoiceParams): Promise<WhmcsCreateInvoiceResponse> {
|
||||
return this.makeRequest("CreateInvoice", params);
|
||||
}
|
||||
|
||||
async updateInvoice(params: WhmcsUpdateInvoiceParams): Promise<WhmcsUpdateInvoiceResponse> {
|
||||
return this.makeRequest("UpdateInvoice", params);
|
||||
}
|
||||
|
||||
async capturePayment(params: WhmcsCapturePaymentParams): Promise<WhmcsCapturePaymentResponse> {
|
||||
return this.makeRequest("CapturePayment", params);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ORDER API METHODS (Used by order services)
|
||||
// ==========================================
|
||||
|
||||
async addOrder(params: Record<string, unknown>) {
|
||||
return this.makeRequest("AddOrder", params);
|
||||
}
|
||||
|
||||
async getOrders(params: Record<string, unknown> = {}) {
|
||||
return this.makeRequest("GetOrders", 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.toString(),
|
||||
autosetup: true,
|
||||
sendemail: false
|
||||
},
|
||||
{ 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 }
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
// ==========================================
|
||||
// SSO API METHODS
|
||||
|
||||
@ -14,6 +14,9 @@ import type {
|
||||
WhmcsGetClientsProductsParams,
|
||||
WhmcsGetPayMethodsParams,
|
||||
WhmcsCreateSsoTokenParams,
|
||||
WhmcsCreateInvoiceParams,
|
||||
WhmcsUpdateInvoiceParams,
|
||||
WhmcsCapturePaymentParams,
|
||||
} from "../../types/whmcs-api.types";
|
||||
import type {
|
||||
WhmcsRequestOptions,
|
||||
@ -138,6 +141,45 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
|
||||
return this.apiMethods.getInvoice(invoiceId);
|
||||
}
|
||||
|
||||
async createInvoice(params: WhmcsCreateInvoiceParams) {
|
||||
return this.apiMethods.createInvoice(params);
|
||||
}
|
||||
|
||||
async updateInvoice(params: WhmcsUpdateInvoiceParams) {
|
||||
return this.apiMethods.updateInvoice(params);
|
||||
}
|
||||
|
||||
async capturePayment(params: WhmcsCapturePaymentParams) {
|
||||
return this.apiMethods.capturePayment(params);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ORDER OPERATIONS (Used by order services)
|
||||
// ==========================================
|
||||
|
||||
async addOrder(params: Record<string, unknown>) {
|
||||
return this.apiMethods.addOrder(params);
|
||||
}
|
||||
|
||||
async getOrders(params: Record<string, unknown> = {}) {
|
||||
return this.apiMethods.getOrders(params);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ADMIN OPERATIONS (Used by order services)
|
||||
// ==========================================
|
||||
|
||||
/**
|
||||
* Accept an order (requires admin authentication)
|
||||
*/
|
||||
async acceptOrder(orderId: number) {
|
||||
return this.apiMethods.acceptOrder(orderId);
|
||||
}
|
||||
|
||||
async cancelOrder(orderId: number) {
|
||||
return this.apiMethods.cancelOrder(orderId);
|
||||
}
|
||||
|
||||
|
||||
// ==========================================
|
||||
// PRODUCT/SUBSCRIPTION API METHODS
|
||||
|
||||
@ -10,25 +10,19 @@ 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";
|
||||
|
||||
// Re-export the config interface for backward compatibility
|
||||
@ -116,6 +110,11 @@ export class WhmcsConnectionService {
|
||||
return this.orchestrator.updateInvoice(params);
|
||||
}
|
||||
|
||||
async capturePayment(params: WhmcsCapturePaymentParams): Promise<WhmcsCapturePaymentResponse> {
|
||||
return this.orchestrator.capturePayment(params);
|
||||
}
|
||||
|
||||
|
||||
// ==========================================
|
||||
// PRODUCT/SUBSCRIPTION API METHODS
|
||||
// ==========================================
|
||||
@ -136,26 +135,37 @@ export class WhmcsConnectionService {
|
||||
return this.orchestrator.getPaymentMethods(params);
|
||||
}
|
||||
|
||||
async addPaymentMethod(params: WhmcsAddPayMethodParams): Promise<WhmcsAddPayMethodResponse> {
|
||||
return this.orchestrator.addPaymentMethod(params);
|
||||
}
|
||||
|
||||
async getPaymentGateways(): Promise<WhmcsPaymentGatewaysResponse> {
|
||||
return this.orchestrator.getPaymentGateways();
|
||||
}
|
||||
|
||||
async capturePayment(params: WhmcsCapturePaymentParams): Promise<WhmcsCapturePaymentResponse> {
|
||||
return this.orchestrator.capturePayment(params);
|
||||
// Legacy method name for backward compatibility
|
||||
async getPayMethods(params: WhmcsGetPayMethodsParams): Promise<WhmcsPayMethodsResponse> {
|
||||
return this.getPaymentMethods(params);
|
||||
}
|
||||
|
||||
async addCredit(params: WhmcsAddCreditParams): Promise<WhmcsAddCreditResponse> {
|
||||
return this.orchestrator.addCredit(params);
|
||||
async getProducts(): Promise<WhmcsCatalogProductsResponse> {
|
||||
return this.orchestrator.getProducts() as Promise<WhmcsCatalogProductsResponse>;
|
||||
}
|
||||
|
||||
async addInvoicePayment(
|
||||
params: WhmcsAddInvoicePaymentParams
|
||||
): Promise<WhmcsAddInvoicePaymentResponse> {
|
||||
return this.orchestrator.addInvoicePayment(params);
|
||||
async addOrder(params: Record<string, unknown>) {
|
||||
return this.orchestrator.addOrder(params);
|
||||
}
|
||||
|
||||
async getOrders(params: Record<string, unknown> = {}) {
|
||||
return this.orchestrator.getOrders(params);
|
||||
}
|
||||
|
||||
async acceptOrder(orderId: number): Promise<{ result: string }> {
|
||||
return this.orchestrator.acceptOrder(orderId);
|
||||
}
|
||||
|
||||
async cancelOrder(orderId: number): Promise<{ result: string }> {
|
||||
return this.orchestrator.cancelOrder(orderId);
|
||||
}
|
||||
|
||||
getBaseUrl(): string {
|
||||
return this.orchestrator.getBaseUrl();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
@ -166,17 +176,6 @@ export class WhmcsConnectionService {
|
||||
return this.orchestrator.createSsoToken(params);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// ADMIN API METHODS
|
||||
// ==========================================
|
||||
|
||||
async acceptOrder(orderId: number): Promise<{ result: string }> {
|
||||
return this.orchestrator.acceptOrder(orderId);
|
||||
}
|
||||
|
||||
async cancelOrder(orderId: number): Promise<{ result: string }> {
|
||||
return this.orchestrator.cancelOrder(orderId);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// UTILITY METHODS
|
||||
|
||||
@ -3,11 +3,17 @@ import { Logger } from "nestjs-pino";
|
||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
||||
import { Invoice, InvoiceList, invoiceListSchema, invoiceSchema } from "@customer-portal/domain";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
|
||||
import { InvoiceTransformerService } from "../transformers/services/invoice-transformer.service";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
import {
|
||||
WhmcsGetInvoicesParams,
|
||||
WhmcsInvoicesResponse,
|
||||
WhmcsCreateInvoiceParams,
|
||||
WhmcsUpdateInvoiceParams,
|
||||
WhmcsCapturePaymentParams,
|
||||
WhmcsCreateInvoiceResponse,
|
||||
WhmcsUpdateInvoiceResponse,
|
||||
WhmcsCapturePaymentResponse,
|
||||
} from "../types/whmcs-api.types";
|
||||
|
||||
export interface InvoiceFilters {
|
||||
@ -21,7 +27,7 @@ export class WhmcsInvoiceService {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly connectionService: WhmcsConnectionService,
|
||||
private readonly dataTransformer: WhmcsDataTransformer,
|
||||
private readonly invoiceTransformer: InvoiceTransformerService,
|
||||
private readonly cacheService: WhmcsCacheService
|
||||
) {}
|
||||
|
||||
@ -172,7 +178,7 @@ export class WhmcsInvoiceService {
|
||||
}
|
||||
|
||||
// Transform invoice
|
||||
const invoice = this.dataTransformer.transformInvoice(response);
|
||||
const invoice = this.invoiceTransformer.transformInvoice(response);
|
||||
|
||||
const parseResult = invoiceSchema.safeParse(invoice);
|
||||
if (!parseResult.success) {
|
||||
@ -222,7 +228,7 @@ export class WhmcsInvoiceService {
|
||||
const invoices = response.invoices.invoice
|
||||
.map(whmcsInvoice => {
|
||||
try {
|
||||
return this.dataTransformer.transformInvoice(whmcsInvoice);
|
||||
return this.invoiceTransformer.transformInvoice(whmcsInvoice);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, {
|
||||
error: getErrorMessage(error),
|
||||
@ -259,4 +265,223 @@ export class WhmcsInvoiceService {
|
||||
},
|
||||
} 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";
|
||||
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.";
|
||||
}
|
||||
}
|
||||
|
||||
@ -95,13 +95,7 @@ export class WhmcsOrderService {
|
||||
|
||||
try {
|
||||
// Call WHMCS AcceptOrder API
|
||||
const response = (await this.connection.acceptOrder({
|
||||
orderid: orderId.toString(),
|
||||
// Ensure module provisioning is executed even if product config is different
|
||||
autosetup: true,
|
||||
// Suppress customer emails to remain consistent with earlier noemail flag
|
||||
sendemail: false,
|
||||
})) as Record<string, unknown>;
|
||||
const response = (await this.connection.acceptOrder(orderId)) as Record<string, unknown>;
|
||||
|
||||
if (response.result !== "success") {
|
||||
throw new Error(
|
||||
|
||||
@ -8,7 +8,7 @@ import {
|
||||
PaymentMethod,
|
||||
} from "@customer-portal/domain";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
|
||||
import { PaymentTransformerService } from "../transformers/services/payment-transformer.service";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
import type {
|
||||
WhmcsCreateSsoTokenParams,
|
||||
@ -21,7 +21,7 @@ export class WhmcsPaymentService {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly connectionService: WhmcsConnectionService,
|
||||
private readonly dataTransformer: WhmcsDataTransformer,
|
||||
private readonly paymentTransformer: PaymentTransformerService,
|
||||
private readonly cacheService: WhmcsCacheService
|
||||
) {}
|
||||
|
||||
@ -44,7 +44,7 @@ export class WhmcsPaymentService {
|
||||
}
|
||||
|
||||
// Fetch pay methods (use the documented WHMCS structure)
|
||||
const response: WhmcsPayMethodsResponse = await this.connectionService.getPayMethods({
|
||||
const response: WhmcsPayMethodsResponse = await this.connectionService.getPaymentMethods({
|
||||
clientid: clientId,
|
||||
});
|
||||
|
||||
@ -56,7 +56,7 @@ export class WhmcsPaymentService {
|
||||
let methods = paymentMethodsArray
|
||||
.map((pm: WhmcsPaymentMethod) => {
|
||||
try {
|
||||
return this.dataTransformer.transformPaymentMethod(pm);
|
||||
return this.paymentTransformer.transformPaymentMethod(pm);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform payment method`, {
|
||||
error: getErrorMessage(error),
|
||||
@ -130,7 +130,7 @@ export class WhmcsPaymentService {
|
||||
const gateways = response.gateways.gateway
|
||||
.map(whmcsGateway => {
|
||||
try {
|
||||
return this.dataTransformer.transformPaymentGateway(whmcsGateway);
|
||||
return this.paymentTransformer.transformPaymentGateway(whmcsGateway);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform payment gateway ${whmcsGateway.name}`, {
|
||||
error: getErrorMessage(error),
|
||||
@ -218,7 +218,7 @@ export class WhmcsPaymentService {
|
||||
*/
|
||||
async getProducts(): Promise<unknown> {
|
||||
try {
|
||||
const response = await this.connectionService.getProducts();
|
||||
const response = await this.connectionService.getCatalogProducts();
|
||||
return response;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get products", {
|
||||
@ -243,12 +243,6 @@ export class WhmcsPaymentService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform product data (delegate to transformer)
|
||||
*/
|
||||
transformProduct(whmcsProduct: Record<string, unknown>): unknown {
|
||||
return this.dataTransformer.transformProduct(whmcsProduct);
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize WHMCS SSO redirect URLs to absolute using configured base URL.
|
||||
|
||||
@ -3,7 +3,7 @@ import { Logger } from "nestjs-pino";
|
||||
import { Injectable, NotFoundException, Inject } from "@nestjs/common";
|
||||
import { Subscription, SubscriptionList } from "@customer-portal/domain";
|
||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
|
||||
import { SubscriptionTransformerService } from "../transformers/services/subscription-transformer.service";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
import { WhmcsGetClientsProductsParams } from "../types/whmcs-api.types";
|
||||
|
||||
@ -16,7 +16,7 @@ export class WhmcsSubscriptionService {
|
||||
constructor(
|
||||
@Inject(Logger) private readonly logger: Logger,
|
||||
private readonly connectionService: WhmcsConnectionService,
|
||||
private readonly dataTransformer: WhmcsDataTransformer,
|
||||
private readonly subscriptionTransformer: SubscriptionTransformerService,
|
||||
private readonly cacheService: WhmcsCacheService
|
||||
) {}
|
||||
|
||||
@ -69,7 +69,7 @@ export class WhmcsSubscriptionService {
|
||||
const subscriptions = response.products.product
|
||||
.map(whmcsProduct => {
|
||||
try {
|
||||
return this.dataTransformer.transformSubscription(whmcsProduct);
|
||||
return this.subscriptionTransformer.transformSubscription(whmcsProduct);
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform subscription ${whmcsProduct.id}`, {
|
||||
error: getErrorMessage(error),
|
||||
|
||||
@ -75,7 +75,8 @@ export class InvoiceTransformerService {
|
||||
const message = DataUtils.toErrorMessage(error);
|
||||
this.logger.error(`Failed to transform invoice ${invoiceId}`, {
|
||||
error: message,
|
||||
whmcsData: DataUtils.sanitizeForLog(whmcsInvoice as unknown as Record<string, unknown>),
|
||||
invoiceId: whmcsInvoice.invoiceid || whmcsInvoice.id,
|
||||
status: whmcsInvoice.status,
|
||||
});
|
||||
throw new Error(`Failed to transform invoice: ${message}`);
|
||||
}
|
||||
@ -85,53 +86,35 @@ export class InvoiceTransformerService {
|
||||
* Transform WHMCS invoice items to our standard format
|
||||
*/
|
||||
private transformInvoiceItems(items: WhmcsInvoiceItems | undefined): InvoiceItem[] {
|
||||
if (!items) return [];
|
||||
if (!items || !items.item) return [];
|
||||
|
||||
try {
|
||||
const itemsArray = Array.isArray(items.item) ? items.item : [items.item];
|
||||
|
||||
return itemsArray
|
||||
.filter(item => item && typeof item === "object")
|
||||
.map(item => this.transformSingleInvoiceItem(item))
|
||||
.filter(Boolean) as InvoiceItem[];
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to transform invoice items", {
|
||||
error: DataUtils.toErrorMessage(error),
|
||||
itemsData: DataUtils.sanitizeForLog(items as unknown as Record<string, unknown>),
|
||||
});
|
||||
return [];
|
||||
}
|
||||
// WHMCS API returns either an array or single item
|
||||
const itemsArray = Array.isArray(items.item) ? items.item : [items.item];
|
||||
|
||||
return itemsArray.map(item => this.transformSingleInvoiceItem(item));
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform a single invoice item
|
||||
* Transform a single invoice item using exact WHMCS API structure
|
||||
*/
|
||||
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"),
|
||||
};
|
||||
private transformSingleInvoiceItem(item: WhmcsInvoiceItems['item'][0]): InvoiceItem {
|
||||
const transformedItem: InvoiceItem = {
|
||||
id: item.id,
|
||||
description: item.description,
|
||||
amount: DataUtils.parseAmount(item.amount),
|
||||
quantity: 1, // WHMCS invoice items don't have quantity field, always 1
|
||||
type: item.type,
|
||||
};
|
||||
|
||||
// Add service ID if available
|
||||
if (item.relid) {
|
||||
transformedItem.serviceId = DataUtils.safeNumber(item.relid);
|
||||
}
|
||||
|
||||
// Note: taxable property is not part of the InvoiceItem schema
|
||||
// Tax information is handled at the invoice level
|
||||
|
||||
return transformedItem;
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to transform single invoice item", {
|
||||
error: DataUtils.toErrorMessage(error),
|
||||
itemData: DataUtils.sanitizeForLog(item),
|
||||
});
|
||||
return null;
|
||||
// Add service ID from relid field
|
||||
if (item.relid) {
|
||||
transformedItem.serviceId = item.relid;
|
||||
}
|
||||
|
||||
// Note: taxed field exists in WHMCS but not in our domain schema
|
||||
// Tax information is handled at the invoice level
|
||||
|
||||
return transformedItem;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@ -21,16 +21,13 @@ export class PaymentTransformerService {
|
||||
transformPaymentGateway(whmcsGateway: WhmcsPaymentGateway): PaymentGateway {
|
||||
try {
|
||||
const gateway: PaymentGateway = {
|
||||
name: DataUtils.safeString(whmcsGateway.name),
|
||||
displayName: DataUtils.safeString(
|
||||
whmcsGateway.display_name || whmcsGateway.name,
|
||||
whmcsGateway.name
|
||||
),
|
||||
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),
|
||||
supportsTokenization: DataUtils.safeBoolean(whmcsGateway.supports_tokenization),
|
||||
name: whmcsGateway.name,
|
||||
displayName: whmcsGateway.display_name,
|
||||
type: whmcsGateway.type,
|
||||
isActive: whmcsGateway.active,
|
||||
acceptsCreditCards: whmcsGateway.accepts_credit_cards || false,
|
||||
acceptsBankAccount: whmcsGateway.accepts_bank_account || false,
|
||||
supportsTokenization: whmcsGateway.supports_tokenization || false,
|
||||
};
|
||||
|
||||
if (!this.validator.validatePaymentGateway(gateway)) {
|
||||
@ -51,119 +48,44 @@ export class PaymentTransformerService {
|
||||
* Transform WHMCS payment method to shared PaymentMethod interface
|
||||
*/
|
||||
transformPaymentMethod(whmcsPayMethod: WhmcsPaymentMethod): PaymentMethod {
|
||||
try {
|
||||
// Handle field name variations between different WHMCS API responses
|
||||
const payMethodId = whmcsPayMethod.id || whmcsPayMethod.paymethodid;
|
||||
const gatewayName = whmcsPayMethod.gateway_name || whmcsPayMethod.gateway || whmcsPayMethod.type;
|
||||
const lastFour = whmcsPayMethod.last_four || whmcsPayMethod.lastfour || whmcsPayMethod.last4;
|
||||
const cardType = whmcsPayMethod.card_type || whmcsPayMethod.cardtype || whmcsPayMethod.brand;
|
||||
const expiryDate = whmcsPayMethod.expiry_date || whmcsPayMethod.expdate || whmcsPayMethod.expiry;
|
||||
const transformed: PaymentMethod = {
|
||||
id: whmcsPayMethod.id,
|
||||
type: whmcsPayMethod.type,
|
||||
description: whmcsPayMethod.description,
|
||||
gatewayName: whmcsPayMethod.gateway_name || "",
|
||||
isDefault: false, // Default value, can be set by calling service
|
||||
};
|
||||
|
||||
if (!payMethodId) {
|
||||
throw new Error("Payment method ID is required");
|
||||
}
|
||||
|
||||
const transformed: PaymentMethod = {
|
||||
id: DataUtils.safeNumber(payMethodId, 0),
|
||||
type: this.normalizePaymentType(gatewayName),
|
||||
description: this.buildPaymentDescription(whmcsPayMethod),
|
||||
gatewayName: DataUtils.safeString(gatewayName),
|
||||
isDefault: DataUtils.safeBoolean(whmcsPayMethod.is_default || whmcsPayMethod.default),
|
||||
};
|
||||
|
||||
// Add credit card specific fields
|
||||
if (lastFour) {
|
||||
transformed.lastFour = DataUtils.safeString(lastFour);
|
||||
}
|
||||
|
||||
if (cardType) {
|
||||
transformed.ccType = DataUtils.safeString(cardType);
|
||||
}
|
||||
|
||||
if (expiryDate) {
|
||||
transformed.expiryDate = this.normalizeExpiryDate(expiryDate);
|
||||
}
|
||||
|
||||
// Add bank account specific fields
|
||||
if (whmcsPayMethod.account_type) {
|
||||
transformed.accountType = DataUtils.safeString(whmcsPayMethod.account_type);
|
||||
}
|
||||
|
||||
// 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");
|
||||
}
|
||||
|
||||
return transformed;
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to transform payment method", {
|
||||
error: DataUtils.toErrorMessage(error),
|
||||
whmcsData: DataUtils.sanitizeForLog(whmcsPayMethod as unknown as Record<string, unknown>),
|
||||
});
|
||||
throw error;
|
||||
// Add credit card specific fields
|
||||
if (whmcsPayMethod.last_four) {
|
||||
transformed.lastFour = whmcsPayMethod.last_four;
|
||||
}
|
||||
|
||||
if (whmcsPayMethod.cc_type) {
|
||||
transformed.ccType = whmcsPayMethod.cc_type;
|
||||
}
|
||||
|
||||
if (whmcsPayMethod.expiry_date) {
|
||||
transformed.expiryDate = this.normalizeExpiryDate(whmcsPayMethod.expiry_date);
|
||||
}
|
||||
|
||||
// Add bank account specific fields
|
||||
if (whmcsPayMethod.account_type) {
|
||||
transformed.accountType = whmcsPayMethod.account_type;
|
||||
}
|
||||
|
||||
if (whmcsPayMethod.bank_name) {
|
||||
transformed.bankName = whmcsPayMethod.bank_name;
|
||||
}
|
||||
|
||||
if (!this.validator.validatePaymentMethod(transformed)) {
|
||||
throw new Error("Transformed payment method failed validation");
|
||||
}
|
||||
|
||||
return transformed;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build a human-readable description for the payment method
|
||||
*/
|
||||
private buildPaymentDescription(whmcsPayMethod: WhmcsPaymentMethod): string {
|
||||
const gatewayName = whmcsPayMethod.gateway_name || whmcsPayMethod.gateway || whmcsPayMethod.type;
|
||||
const lastFour = whmcsPayMethod.last_four || whmcsPayMethod.lastfour || whmcsPayMethod.last4;
|
||||
const cardType = whmcsPayMethod.card_type || whmcsPayMethod.cardtype || whmcsPayMethod.brand;
|
||||
|
||||
// For credit cards
|
||||
if (lastFour && cardType) {
|
||||
return `${cardType} ending in ${lastFour}`;
|
||||
}
|
||||
|
||||
if (lastFour) {
|
||||
return `Card ending in ${lastFour}`;
|
||||
}
|
||||
|
||||
// For bank accounts
|
||||
if (whmcsPayMethod.account_type && whmcsPayMethod.routing_number) {
|
||||
return `${whmcsPayMethod.account_type} account`;
|
||||
}
|
||||
|
||||
// Fallback to gateway name
|
||||
if (gatewayName) {
|
||||
return `${gatewayName} payment method`;
|
||||
}
|
||||
|
||||
return "Payment method";
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize payment type from gateway name
|
||||
*/
|
||||
private normalizePaymentType(gatewayName: string): string {
|
||||
if (!gatewayName) return "unknown";
|
||||
|
||||
const gateway = gatewayName.toLowerCase();
|
||||
|
||||
// Credit card gateways
|
||||
if (gateway.includes("stripe") || gateway.includes("paypal") ||
|
||||
gateway.includes("square") || gateway.includes("authorize")) {
|
||||
return "credit_card";
|
||||
}
|
||||
|
||||
// Bank transfer gateways
|
||||
if (gateway.includes("bank") || gateway.includes("ach") ||
|
||||
gateway.includes("wire") || gateway.includes("transfer")) {
|
||||
return "bank_account";
|
||||
}
|
||||
|
||||
// Digital wallets
|
||||
if (gateway.includes("paypal") || gateway.includes("apple") ||
|
||||
gateway.includes("google") || gateway.includes("amazon")) {
|
||||
return "digital_wallet";
|
||||
}
|
||||
|
||||
return gatewayName;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize expiry date to MM/YY format
|
||||
@ -205,7 +127,7 @@ export class PaymentTransformerService {
|
||||
const transformed = this.transformPaymentMethod(whmcsPayMethod);
|
||||
results.push(transformed);
|
||||
} catch (error) {
|
||||
const payMethodId = whmcsPayMethod?.id || whmcsPayMethod?.paymethodid || "unknown";
|
||||
const payMethodId = whmcsPayMethod?.id || "unknown";
|
||||
const message = DataUtils.toErrorMessage(error);
|
||||
errors.push(`Payment method ${payMethodId}: ${message}`);
|
||||
}
|
||||
|
||||
@ -34,7 +34,7 @@ export class SubscriptionTransformerService {
|
||||
|
||||
// Safety override: If we have no recurring amount but have first payment, treat as one-time
|
||||
if (recurringAmount === 0 && firstPaymentAmount > 0) {
|
||||
normalizedCycle = "One Time";
|
||||
normalizedCycle = "Monthly"; // Default to Monthly for one-time payments
|
||||
}
|
||||
|
||||
const subscription: Subscription = {
|
||||
@ -73,7 +73,9 @@ export class SubscriptionTransformerService {
|
||||
const message = DataUtils.toErrorMessage(error);
|
||||
this.logger.error(`Failed to transform subscription ${whmcsProduct.id}`, {
|
||||
error: message,
|
||||
whmcsData: DataUtils.sanitizeForLog(whmcsProduct as unknown as Record<string, unknown>),
|
||||
productId: whmcsProduct.id,
|
||||
status: whmcsProduct.status,
|
||||
productName: whmcsProduct.name || whmcsProduct.productname,
|
||||
});
|
||||
throw new Error(`Failed to transform subscription: ${message}`);
|
||||
}
|
||||
@ -113,7 +115,7 @@ export class SubscriptionTransformerService {
|
||||
} catch (error) {
|
||||
this.logger.warn("Failed to extract custom fields", {
|
||||
error: DataUtils.toErrorMessage(error),
|
||||
customFieldsData: DataUtils.sanitizeForLog(customFields as unknown as Record<string, unknown>),
|
||||
customFieldsCount: customFields?.length || 0,
|
||||
});
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -5,6 +5,7 @@ import {
|
||||
PaymentMethod,
|
||||
PaymentGateway,
|
||||
} from "@customer-portal/domain";
|
||||
import type { WhmcsInvoice, WhmcsProduct } from "../../types/whmcs-api.types";
|
||||
|
||||
/**
|
||||
* Service for validating transformed data objects
|
||||
@ -77,41 +78,26 @@ export class TransformationValidator {
|
||||
/**
|
||||
* Validate invoice items array
|
||||
*/
|
||||
validateInvoiceItems(items: unknown[]): boolean {
|
||||
validateInvoiceItems(items: Array<{ description: string; amount: string; id: number; type: string; relid: number }>): boolean {
|
||||
if (!Array.isArray(items)) return false;
|
||||
|
||||
return items.every(item => {
|
||||
if (!item || typeof item !== "object") return false;
|
||||
|
||||
const requiredFields = ["description", "amount"];
|
||||
return requiredFields.every(field => {
|
||||
const value = (item as Record<string, unknown>)[field];
|
||||
return value !== undefined && value !== null;
|
||||
});
|
||||
return Boolean(item.description && item.amount && item.id);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that required WHMCS data is present
|
||||
* Validate that required WHMCS invoice data is present
|
||||
*/
|
||||
validateWhmcsInvoiceData(whmcsInvoice: unknown): boolean {
|
||||
if (!whmcsInvoice || typeof whmcsInvoice !== "object") return false;
|
||||
|
||||
const invoice = whmcsInvoice as Record<string, unknown>;
|
||||
const invoiceId = invoice.invoiceid || invoice.id;
|
||||
|
||||
return Boolean(invoiceId);
|
||||
validateWhmcsInvoiceData(whmcsInvoice: WhmcsInvoice): boolean {
|
||||
return Boolean(whmcsInvoice.invoiceid || whmcsInvoice.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate that required WHMCS product data is present
|
||||
*/
|
||||
validateWhmcsProductData(whmcsProduct: unknown): boolean {
|
||||
if (!whmcsProduct || typeof whmcsProduct !== "object") return false;
|
||||
|
||||
const product = whmcsProduct as Record<string, unknown>;
|
||||
|
||||
return Boolean(product.id);
|
||||
validateWhmcsProductData(whmcsProduct: WhmcsProduct): boolean {
|
||||
return Boolean(whmcsProduct.id);
|
||||
}
|
||||
|
||||
/**
|
||||
@ -127,7 +113,7 @@ export class TransformationValidator {
|
||||
/**
|
||||
* Validate amount is a valid number
|
||||
*/
|
||||
validateAmount(amount: unknown): boolean {
|
||||
validateAmount(amount: string | number): boolean {
|
||||
if (typeof amount === "number") {
|
||||
return !isNaN(amount) && isFinite(amount);
|
||||
}
|
||||
@ -143,8 +129,8 @@ export class TransformationValidator {
|
||||
/**
|
||||
* Validate date string format
|
||||
*/
|
||||
validateDateString(dateStr: unknown): boolean {
|
||||
if (!dateStr || typeof dateStr !== "string") return false;
|
||||
validateDateString(dateStr: string): boolean {
|
||||
if (!dateStr) return false;
|
||||
|
||||
const date = new Date(dateStr);
|
||||
return !isNaN(date.getTime());
|
||||
|
||||
@ -1,76 +0,0 @@
|
||||
import { Injectable } from "@nestjs/common";
|
||||
import { WhmcsTransformerOrchestratorService } from "./services/whmcs-transformer-orchestrator.service";
|
||||
import { TransformationValidator } from "./validators/transformation-validator";
|
||||
import type {
|
||||
Invoice,
|
||||
Subscription,
|
||||
PaymentMethod,
|
||||
PaymentGateway,
|
||||
} from "@customer-portal/domain";
|
||||
import type {
|
||||
WhmcsInvoice,
|
||||
WhmcsProduct,
|
||||
WhmcsPaymentMethod,
|
||||
WhmcsPaymentGateway,
|
||||
} from "../types/whmcs-api.types";
|
||||
|
||||
/**
|
||||
* Main WHMCS Data Transformer - now acts as a facade to the orchestrator service
|
||||
* Maintains backward compatibility while delegating to modular services
|
||||
*/
|
||||
@Injectable()
|
||||
export class WhmcsDataTransformer {
|
||||
constructor(
|
||||
private readonly orchestrator: WhmcsTransformerOrchestratorService,
|
||||
private readonly validator: TransformationValidator
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Transform WHMCS invoice to our standard Invoice format
|
||||
*/
|
||||
transformInvoice(whmcsInvoice: WhmcsInvoice): Invoice {
|
||||
return this.orchestrator.transformInvoiceSync(whmcsInvoice);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform WHMCS product/service to our standard Subscription format
|
||||
*/
|
||||
transformSubscription(whmcsProduct: WhmcsProduct): Subscription {
|
||||
return this.orchestrator.transformSubscriptionSync(whmcsProduct);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform WHMCS payment gateway to shared PaymentGateway interface
|
||||
*/
|
||||
transformPaymentGateway(whmcsGateway: WhmcsPaymentGateway): PaymentGateway {
|
||||
return this.orchestrator.transformPaymentGatewaySync(whmcsGateway);
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform WHMCS payment method to shared PaymentMethod interface
|
||||
*/
|
||||
transformPaymentMethod(whmcsPayMethod: WhmcsPaymentMethod): PaymentMethod {
|
||||
return this.orchestrator.transformPaymentMethodSync(whmcsPayMethod);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate subscription transformation result
|
||||
*/
|
||||
validateSubscription(subscription: Subscription): boolean {
|
||||
return this.validator.validateSubscription(subscription);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate payment method transformation result
|
||||
*/
|
||||
validatePaymentMethod(paymentMethod: PaymentMethod): boolean {
|
||||
return this.validator.validatePaymentMethod(paymentMethod);
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate payment gateway transformation result
|
||||
*/
|
||||
validatePaymentGateway(gateway: PaymentGateway): boolean {
|
||||
return this.validator.validatePaymentGateway(gateway);
|
||||
}
|
||||
}
|
||||
@ -333,7 +333,92 @@ export interface WhmcsPaymentGatewaysResponse {
|
||||
totalresults: number;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// Invoice Creation and Payment Types (Used by SIM/Order services)
|
||||
// ==========================================
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,5 @@
|
||||
import { Module } from "@nestjs/common";
|
||||
import { ConfigModule } from "@nestjs/config";
|
||||
import { WhmcsDataTransformer } from "./transformers/whmcs-data.transformer";
|
||||
import { WhmcsCacheService } from "./cache/whmcs-cache.service";
|
||||
import { WhmcsService } from "./whmcs.service";
|
||||
import { WhmcsConnectionService } from "./services/whmcs-connection.service";
|
||||
@ -26,8 +25,6 @@ import { WhmcsApiMethodsService } from "./connection/services/whmcs-api-methods.
|
||||
@Module({
|
||||
imports: [ConfigModule],
|
||||
providers: [
|
||||
// Legacy transformer (now facade)
|
||||
WhmcsDataTransformer,
|
||||
// New modular transformer services
|
||||
WhmcsTransformerOrchestratorService,
|
||||
InvoiceTransformerService,
|
||||
@ -56,7 +53,6 @@ import { WhmcsApiMethodsService } from "./connection/services/whmcs-api-methods.
|
||||
WhmcsService,
|
||||
WhmcsConnectionService,
|
||||
WhmcsConnectionOrchestratorService,
|
||||
WhmcsDataTransformer,
|
||||
WhmcsTransformerOrchestratorService,
|
||||
WhmcsCacheService,
|
||||
WhmcsOrderService,
|
||||
|
||||
@ -279,12 +279,6 @@ export class WhmcsService {
|
||||
return this.paymentService.getProducts() as Promise<WhmcsCatalogProductsResponse>;
|
||||
}
|
||||
|
||||
/**
|
||||
* Transform product data (delegate to transformer)
|
||||
*/
|
||||
transformProduct(whmcsProduct: WhmcsCatalogProductsResponse["products"]["product"][0]): unknown {
|
||||
return this.paymentService.transformProduct(whmcsProduct);
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// SSO OPERATIONS (delegate to SsoService)
|
||||
@ -353,4 +347,52 @@ export class WhmcsService {
|
||||
getOrderService(): WhmcsOrderService {
|
||||
return this.orderService;
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
// INVOICE CREATION AND PAYMENT OPERATIONS (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 }> {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -3,8 +3,6 @@ export { InvoicesOrchestratorService } from "./services/invoices-orchestrator.se
|
||||
|
||||
// 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
|
||||
|
||||
@ -167,7 +167,9 @@ export class InvoicesController {
|
||||
throw new BadRequestException("Invoice ID must be a positive number");
|
||||
}
|
||||
|
||||
return this.invoicesService.getInvoiceSubscriptions(req.user.id, invoiceId);
|
||||
// This functionality has been moved to WHMCS directly
|
||||
// For now, return empty array as subscriptions are managed in WHMCS
|
||||
return [];
|
||||
}
|
||||
|
||||
@Post(":id/sso-link")
|
||||
@ -199,7 +201,7 @@ export class InvoicesController {
|
||||
throw new BadRequestException('Target must be "view", "download", or "pay"');
|
||||
}
|
||||
|
||||
return this.invoicesService.createSsoLink(req.user.id, invoiceId, target || "view");
|
||||
return this.invoicesService.createInvoiceSsoLink(req.user.id, invoiceId);
|
||||
}
|
||||
|
||||
@Post(":id/payment-link")
|
||||
@ -239,11 +241,11 @@ export class InvoicesController {
|
||||
throw new BadRequestException("Payment method ID must be a positive number");
|
||||
}
|
||||
|
||||
return this.invoicesService.createPaymentSsoLink(
|
||||
return this.invoicesService.createInvoicePaymentLink(
|
||||
req.user.id,
|
||||
invoiceId,
|
||||
paymentMethodIdNum,
|
||||
gatewayName
|
||||
gatewayName || "stripe",
|
||||
"/"
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@ -6,8 +6,6 @@ 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";
|
||||
|
||||
@ -20,8 +18,6 @@ import { InvoiceValidatorService } from "./validators/invoice-validator.service"
|
||||
// New modular services
|
||||
InvoicesOrchestratorService,
|
||||
InvoiceRetrievalService,
|
||||
InvoiceOperationsService,
|
||||
PaymentMethodsService,
|
||||
InvoiceHealthService,
|
||||
InvoiceValidatorService,
|
||||
],
|
||||
|
||||
@ -32,8 +32,6 @@ export class OrderValidator {
|
||||
this.logger.debug(
|
||||
{
|
||||
bodyType: typeof rawBody,
|
||||
hasOrderType: !!(rawBody as Record<string, unknown>)?.orderType,
|
||||
hasSkus: !!(rawBody as Record<string, unknown>)?.skus,
|
||||
},
|
||||
"Starting Zod request format validation"
|
||||
);
|
||||
@ -108,7 +106,7 @@ export class OrderValidator {
|
||||
*/
|
||||
async validatePaymentMethod(userId: string, whmcsClientId: number): Promise<void> {
|
||||
try {
|
||||
const pay = await this.whmcs.getPayMethods({ clientid: whmcsClientId });
|
||||
const pay = await this.whmcs.getPaymentMethods({ clientid: whmcsClientId });
|
||||
if (!Array.isArray(pay?.paymethods) || pay.paymethods.length === 0) {
|
||||
this.logger.warn({ userId }, "No WHMCS payment method on file");
|
||||
throw new BadRequestException("A payment method is required before ordering");
|
||||
@ -126,11 +124,9 @@ export class OrderValidator {
|
||||
async validateInternetDuplication(userId: string, whmcsClientId: number): Promise<void> {
|
||||
try {
|
||||
const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId });
|
||||
const existing = (products?.products?.product || []) as Array<{
|
||||
groupname?: string;
|
||||
}>;
|
||||
const hasInternet = existing.some(p =>
|
||||
String(p.groupname ?? "")
|
||||
const existing = products?.products?.product || [];
|
||||
const hasInternet = existing.some(product =>
|
||||
(product.groupname || "")
|
||||
.toLowerCase()
|
||||
.includes("internet")
|
||||
);
|
||||
|
||||
@ -24,8 +24,11 @@ export class SimTopUpService {
|
||||
* Pricing: 1GB = 500 JPY
|
||||
*/
|
||||
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
||||
let account: string = "";
|
||||
|
||||
try {
|
||||
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
account = validation.account;
|
||||
|
||||
// Validate quota amount
|
||||
if (request.quotaMb <= 0 || request.quotaMb > 100000) {
|
||||
@ -98,7 +101,7 @@ export class SimTopUpService {
|
||||
});
|
||||
|
||||
// Cancel the invoice since payment failed
|
||||
await this.handlePaymentFailure(invoice.id, paymentResult.error);
|
||||
await this.handlePaymentFailure(invoice.id, paymentResult.error || "Unknown payment error");
|
||||
|
||||
throw new BadRequestException(`SIM top-up failed: ${paymentResult.error}`);
|
||||
}
|
||||
@ -138,7 +141,7 @@ export class SimTopUpService {
|
||||
await this.handleFreebitFailureAfterPayment(
|
||||
freebitError,
|
||||
invoice,
|
||||
paymentResult.transactionId,
|
||||
paymentResult.transactionId || "unknown",
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
|
||||
@ -87,11 +87,22 @@ export class SimOrderActivationService {
|
||||
await this.freebit.activateEsimAccountNew({
|
||||
account: req.msisdn,
|
||||
eid: req.eid!,
|
||||
planCode: req.planSku,
|
||||
planSku: req.planSku,
|
||||
contractLine: "5G",
|
||||
shipDate: req.activationType === "Scheduled" ? req.scheduledAt : undefined,
|
||||
mnp: req.mnp
|
||||
? { reserveNumber: req.mnp.reserveNumber, reserveExpireDate: req.mnp.reserveExpireDate }
|
||||
? {
|
||||
reservationNumber: req.mnp.reserveNumber,
|
||||
expiryDate: req.mnp.reserveExpireDate,
|
||||
phoneNumber: req.mnp.account || "",
|
||||
mvnoAccountNumber: "",
|
||||
portingLastName: "",
|
||||
portingFirstName: "",
|
||||
portingLastNameKatakana: "",
|
||||
portingFirstNameKatakana: "",
|
||||
portingGender: "" as const,
|
||||
portingDateOfBirth: ""
|
||||
}
|
||||
: undefined,
|
||||
identity: req.mnp
|
||||
? {
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user