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:
barsa 2025-09-25 16:23:24 +09:00
parent 065e2f9acf
commit 9fafd227b9
24 changed files with 677 additions and 385 deletions

View File

@ -24,7 +24,7 @@ export class FreebitClientService {
*/ */
async makeAuthenticatedRequest< async makeAuthenticatedRequest<
TResponse extends FreebitResponseBase, TResponse extends FreebitResponseBase,
TPayload extends Record<string, unknown>, TPayload extends object,
>(endpoint: string, payload: TPayload): Promise<TResponse> { >(endpoint: string, payload: TPayload): Promise<TResponse> {
const authKey = await this.authService.getAuthKey(); const authKey = await this.authService.getAuthKey();
const config = this.authService.getConfig(); const config = this.authService.getConfig();

View File

@ -10,11 +10,17 @@ import {
statusKey as sfStatusKey, statusKey as sfStatusKey,
latestSeenKey as sfLatestSeenKey, latestSeenKey as sfLatestSeenKey,
} from "./event-keys.util"; } from "./event-keys.util";
import type {
SalesforcePubSubEvent,
SalesforcePubSubError,
SalesforcePubSubSubscription,
SalesforcePubSubCallbackType,
} from "../types/pubsub-events.types";
type SubscribeCallback = ( type SubscribeCallback = (
subscription: { topicName: string }, subscription: SalesforcePubSubSubscription,
callbackType: string, callbackType: SalesforcePubSubCallbackType,
data: unknown data: SalesforcePubSubEvent | SalesforcePubSubError | unknown
) => void | Promise<void>; ) => void | Promise<void>;
interface PubSubClient { interface PubSubClient {
@ -110,27 +116,17 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
const argTypes = [typeof subscription, typeof callbackType, typeof data]; const argTypes = [typeof subscription, typeof callbackType, typeof data];
const type = callbackType; const type = callbackType;
const typeNorm = String(type || "").toLowerCase(); 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") { 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 // Basic breadcrumb to confirm we are handling data callbacks
this.logger.debug("SF Pub/Sub data callback received", { this.logger.debug("SF Pub/Sub data callback received", {
topic, topic,
argTypes, argTypes,
hasPayload: ((): boolean => { hasPayload: Boolean(event?.payload),
if (!event || typeof event !== "object") return false;
const maybePayload = event["payload"];
return typeof maybePayload === "object" && maybePayload !== null;
})(),
}); });
const payload = ((): Record<string, unknown> | undefined => { const payload = event?.payload;
const p = event["payload"];
if (typeof p === "object" && p !== null) {
return p as Record<string, unknown>;
}
return undefined;
})();
// Only check parsed payload // Only check parsed payload
const orderIdVal = payload?.["OrderId__c"] ?? payload?.["OrderId"]; 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 }); this.logger.warn("SF Pub/Sub stream error", { topic, data });
try { try {
// Detect replay id corruption and auto-recover once by clearing the cursor and resubscribing // Detect replay id corruption and auto-recover once by clearing the cursor and resubscribing
const maybeObj = (data || {}) as Record<string, unknown>; const errorData = data as SalesforcePubSubError;
const details = typeof maybeObj["details"] === "string" ? maybeObj["details"] : ""; const details = errorData.details || "";
const metadata = (maybeObj["metadata"] || {}) as Record<string, unknown>; const metadata = errorData.metadata || {};
const errorCodes = Array.isArray((metadata as { [k: string]: unknown })["error-code"]) const errorCodes = Array.isArray(metadata["error-code"]) ? metadata["error-code"] : [];
? ((metadata as { [k: string]: unknown })["error-code"] as unknown[]) const hasCorruptionCode = errorCodes.some(code =>
: []; String(code).includes("replayid.corrupted")
const hasCorruptionCode = errorCodes.some(v =>
String(v).includes("replayid.corrupted")
); );
const mentionsReplayValidation = /Replay ID validation failed/i.test(details); const mentionsReplayValidation = /Replay ID validation failed/i.test(details);
@ -228,12 +222,8 @@ export class SalesforcePubSubSubscriber implements OnModuleInit, OnModuleDestroy
} }
} else { } else {
// Unknown callback type: log once with minimal context // Unknown callback type: log once with minimal context
const maybeEvent = data as Record<string, unknown> | undefined; const maybeEvent = data as SalesforcePubSubEvent | undefined;
const hasPayload = ((): boolean => { const hasPayload = Boolean(maybeEvent?.payload);
if (!maybeEvent || typeof maybeEvent !== "object") return false;
const p = maybeEvent["payload"];
return typeof p === "object" && p !== null;
})();
this.logger.debug("SF Pub/Sub callback ignored (unknown type)", { this.logger.debug("SF Pub/Sub callback ignored (unknown type)", {
type, type,
topic, topic,

View File

@ -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;
}

View File

@ -10,12 +10,18 @@ import type {
WhmcsCatalogProductsResponse, WhmcsCatalogProductsResponse,
WhmcsPayMethodsResponse, WhmcsPayMethodsResponse,
WhmcsPaymentGatewaysResponse, WhmcsPaymentGatewaysResponse,
WhmcsCreateInvoiceResponse,
WhmcsUpdateInvoiceResponse,
WhmcsCapturePaymentResponse,
WhmcsGetInvoicesParams, WhmcsGetInvoicesParams,
WhmcsGetClientsProductsParams, WhmcsGetClientsProductsParams,
WhmcsCreateSsoTokenParams, WhmcsCreateSsoTokenParams,
WhmcsValidateLoginParams, WhmcsValidateLoginParams,
WhmcsAddClientParams, WhmcsAddClientParams,
WhmcsGetPayMethodsParams, WhmcsGetPayMethodsParams,
WhmcsCreateInvoiceParams,
WhmcsUpdateInvoiceParams,
WhmcsCapturePaymentParams,
} from "../../types/whmcs-api.types"; } from "../../types/whmcs-api.types";
import { WhmcsHttpClientService } from "./whmcs-http-client.service"; import { WhmcsHttpClientService } from "./whmcs-http-client.service";
import { WhmcsConfigService } from "../config/whmcs-config.service"; import { WhmcsConfigService } from "../config/whmcs-config.service";
@ -136,6 +142,62 @@ export class WhmcsApiMethodsService {
return this.makeRequest("GetPaymentMethods", {}); 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 // SSO API METHODS

View File

@ -14,6 +14,9 @@ import type {
WhmcsGetClientsProductsParams, WhmcsGetClientsProductsParams,
WhmcsGetPayMethodsParams, WhmcsGetPayMethodsParams,
WhmcsCreateSsoTokenParams, WhmcsCreateSsoTokenParams,
WhmcsCreateInvoiceParams,
WhmcsUpdateInvoiceParams,
WhmcsCapturePaymentParams,
} from "../../types/whmcs-api.types"; } from "../../types/whmcs-api.types";
import type { import type {
WhmcsRequestOptions, WhmcsRequestOptions,
@ -138,6 +141,45 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
return this.apiMethods.getInvoice(invoiceId); 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 // PRODUCT/SUBSCRIPTION API METHODS

View File

@ -10,25 +10,19 @@ import type {
WhmcsAddClientResponse, WhmcsAddClientResponse,
WhmcsCatalogProductsResponse, WhmcsCatalogProductsResponse,
WhmcsPayMethodsResponse, WhmcsPayMethodsResponse,
WhmcsAddPayMethodResponse,
WhmcsPaymentGatewaysResponse, WhmcsPaymentGatewaysResponse,
WhmcsCreateInvoiceResponse, WhmcsCreateInvoiceResponse,
WhmcsUpdateInvoiceResponse, WhmcsUpdateInvoiceResponse,
WhmcsCapturePaymentResponse, WhmcsCapturePaymentResponse,
WhmcsAddCreditResponse,
WhmcsAddInvoicePaymentResponse,
WhmcsGetInvoicesParams, WhmcsGetInvoicesParams,
WhmcsGetClientsProductsParams, WhmcsGetClientsProductsParams,
WhmcsCreateSsoTokenParams, WhmcsCreateSsoTokenParams,
WhmcsValidateLoginParams, WhmcsValidateLoginParams,
WhmcsAddClientParams, WhmcsAddClientParams,
WhmcsGetPayMethodsParams, WhmcsGetPayMethodsParams,
WhmcsAddPayMethodParams,
WhmcsCreateInvoiceParams, WhmcsCreateInvoiceParams,
WhmcsUpdateInvoiceParams, WhmcsUpdateInvoiceParams,
WhmcsCapturePaymentParams, WhmcsCapturePaymentParams,
WhmcsAddCreditParams,
WhmcsAddInvoicePaymentParams,
} from "../types/whmcs-api.types"; } from "../types/whmcs-api.types";
// Re-export the config interface for backward compatibility // Re-export the config interface for backward compatibility
@ -116,6 +110,11 @@ export class WhmcsConnectionService {
return this.orchestrator.updateInvoice(params); return this.orchestrator.updateInvoice(params);
} }
async capturePayment(params: WhmcsCapturePaymentParams): Promise<WhmcsCapturePaymentResponse> {
return this.orchestrator.capturePayment(params);
}
// ========================================== // ==========================================
// PRODUCT/SUBSCRIPTION API METHODS // PRODUCT/SUBSCRIPTION API METHODS
// ========================================== // ==========================================
@ -136,26 +135,37 @@ export class WhmcsConnectionService {
return this.orchestrator.getPaymentMethods(params); return this.orchestrator.getPaymentMethods(params);
} }
async addPaymentMethod(params: WhmcsAddPayMethodParams): Promise<WhmcsAddPayMethodResponse> {
return this.orchestrator.addPaymentMethod(params);
}
async getPaymentGateways(): Promise<WhmcsPaymentGatewaysResponse> { async getPaymentGateways(): Promise<WhmcsPaymentGatewaysResponse> {
return this.orchestrator.getPaymentGateways(); return this.orchestrator.getPaymentGateways();
} }
async capturePayment(params: WhmcsCapturePaymentParams): Promise<WhmcsCapturePaymentResponse> { // Legacy method name for backward compatibility
return this.orchestrator.capturePayment(params); async getPayMethods(params: WhmcsGetPayMethodsParams): Promise<WhmcsPayMethodsResponse> {
return this.getPaymentMethods(params);
} }
async addCredit(params: WhmcsAddCreditParams): Promise<WhmcsAddCreditResponse> { async getProducts(): Promise<WhmcsCatalogProductsResponse> {
return this.orchestrator.addCredit(params); return this.orchestrator.getProducts() as Promise<WhmcsCatalogProductsResponse>;
} }
async addInvoicePayment( async addOrder(params: Record<string, unknown>) {
params: WhmcsAddInvoicePaymentParams return this.orchestrator.addOrder(params);
): Promise<WhmcsAddInvoicePaymentResponse> { }
return this.orchestrator.addInvoicePayment(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); 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 // UTILITY METHODS

View File

@ -3,11 +3,17 @@ import { Logger } from "nestjs-pino";
import { Injectable, NotFoundException, Inject } from "@nestjs/common"; import { Injectable, NotFoundException, Inject } from "@nestjs/common";
import { Invoice, InvoiceList, invoiceListSchema, invoiceSchema } from "@customer-portal/domain"; import { Invoice, InvoiceList, invoiceListSchema, invoiceSchema } from "@customer-portal/domain";
import { WhmcsConnectionService } from "./whmcs-connection.service"; 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 { WhmcsCacheService } from "../cache/whmcs-cache.service";
import { import {
WhmcsGetInvoicesParams, WhmcsGetInvoicesParams,
WhmcsInvoicesResponse, WhmcsInvoicesResponse,
WhmcsCreateInvoiceParams,
WhmcsUpdateInvoiceParams,
WhmcsCapturePaymentParams,
WhmcsCreateInvoiceResponse,
WhmcsUpdateInvoiceResponse,
WhmcsCapturePaymentResponse,
} from "../types/whmcs-api.types"; } from "../types/whmcs-api.types";
export interface InvoiceFilters { export interface InvoiceFilters {
@ -21,7 +27,7 @@ export class WhmcsInvoiceService {
constructor( constructor(
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger,
private readonly connectionService: WhmcsConnectionService, private readonly connectionService: WhmcsConnectionService,
private readonly dataTransformer: WhmcsDataTransformer, private readonly invoiceTransformer: InvoiceTransformerService,
private readonly cacheService: WhmcsCacheService private readonly cacheService: WhmcsCacheService
) {} ) {}
@ -172,7 +178,7 @@ export class WhmcsInvoiceService {
} }
// Transform invoice // Transform invoice
const invoice = this.dataTransformer.transformInvoice(response); const invoice = this.invoiceTransformer.transformInvoice(response);
const parseResult = invoiceSchema.safeParse(invoice); const parseResult = invoiceSchema.safeParse(invoice);
if (!parseResult.success) { if (!parseResult.success) {
@ -222,7 +228,7 @@ export class WhmcsInvoiceService {
const invoices = response.invoices.invoice const invoices = response.invoices.invoice
.map(whmcsInvoice => { .map(whmcsInvoice => {
try { try {
return this.dataTransformer.transformInvoice(whmcsInvoice); return this.invoiceTransformer.transformInvoice(whmcsInvoice);
} catch (error) { } catch (error) {
this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, { this.logger.error(`Failed to transform invoice ${whmcsInvoice.id}`, {
error: getErrorMessage(error), error: getErrorMessage(error),
@ -259,4 +265,223 @@ export class WhmcsInvoiceService {
}, },
} satisfies InvoiceList; } 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.";
}
} }

View File

@ -95,13 +95,7 @@ export class WhmcsOrderService {
try { try {
// Call WHMCS AcceptOrder API // Call WHMCS AcceptOrder API
const response = (await this.connection.acceptOrder({ const response = (await this.connection.acceptOrder(orderId)) as Record<string, unknown>;
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>;
if (response.result !== "success") { if (response.result !== "success") {
throw new Error( throw new Error(

View File

@ -8,7 +8,7 @@ import {
PaymentMethod, PaymentMethod,
} from "@customer-portal/domain"; } from "@customer-portal/domain";
import { WhmcsConnectionService } from "./whmcs-connection.service"; 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 { WhmcsCacheService } from "../cache/whmcs-cache.service";
import type { import type {
WhmcsCreateSsoTokenParams, WhmcsCreateSsoTokenParams,
@ -21,7 +21,7 @@ export class WhmcsPaymentService {
constructor( constructor(
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger,
private readonly connectionService: WhmcsConnectionService, private readonly connectionService: WhmcsConnectionService,
private readonly dataTransformer: WhmcsDataTransformer, private readonly paymentTransformer: PaymentTransformerService,
private readonly cacheService: WhmcsCacheService private readonly cacheService: WhmcsCacheService
) {} ) {}
@ -44,7 +44,7 @@ export class WhmcsPaymentService {
} }
// Fetch pay methods (use the documented WHMCS structure) // Fetch pay methods (use the documented WHMCS structure)
const response: WhmcsPayMethodsResponse = await this.connectionService.getPayMethods({ const response: WhmcsPayMethodsResponse = await this.connectionService.getPaymentMethods({
clientid: clientId, clientid: clientId,
}); });
@ -56,7 +56,7 @@ export class WhmcsPaymentService {
let methods = paymentMethodsArray let methods = paymentMethodsArray
.map((pm: WhmcsPaymentMethod) => { .map((pm: WhmcsPaymentMethod) => {
try { try {
return this.dataTransformer.transformPaymentMethod(pm); return this.paymentTransformer.transformPaymentMethod(pm);
} catch (error) { } catch (error) {
this.logger.error(`Failed to transform payment method`, { this.logger.error(`Failed to transform payment method`, {
error: getErrorMessage(error), error: getErrorMessage(error),
@ -130,7 +130,7 @@ export class WhmcsPaymentService {
const gateways = response.gateways.gateway const gateways = response.gateways.gateway
.map(whmcsGateway => { .map(whmcsGateway => {
try { try {
return this.dataTransformer.transformPaymentGateway(whmcsGateway); return this.paymentTransformer.transformPaymentGateway(whmcsGateway);
} catch (error) { } catch (error) {
this.logger.error(`Failed to transform payment gateway ${whmcsGateway.name}`, { this.logger.error(`Failed to transform payment gateway ${whmcsGateway.name}`, {
error: getErrorMessage(error), error: getErrorMessage(error),
@ -218,7 +218,7 @@ export class WhmcsPaymentService {
*/ */
async getProducts(): Promise<unknown> { async getProducts(): Promise<unknown> {
try { try {
const response = await this.connectionService.getProducts(); const response = await this.connectionService.getCatalogProducts();
return response; return response;
} catch (error) { } catch (error) {
this.logger.error("Failed to get products", { 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. * Normalize WHMCS SSO redirect URLs to absolute using configured base URL.

View File

@ -3,7 +3,7 @@ import { Logger } from "nestjs-pino";
import { Injectable, NotFoundException, Inject } from "@nestjs/common"; import { Injectable, NotFoundException, Inject } from "@nestjs/common";
import { Subscription, SubscriptionList } from "@customer-portal/domain"; import { Subscription, SubscriptionList } from "@customer-portal/domain";
import { WhmcsConnectionService } from "./whmcs-connection.service"; 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 { WhmcsCacheService } from "../cache/whmcs-cache.service";
import { WhmcsGetClientsProductsParams } from "../types/whmcs-api.types"; import { WhmcsGetClientsProductsParams } from "../types/whmcs-api.types";
@ -16,7 +16,7 @@ export class WhmcsSubscriptionService {
constructor( constructor(
@Inject(Logger) private readonly logger: Logger, @Inject(Logger) private readonly logger: Logger,
private readonly connectionService: WhmcsConnectionService, private readonly connectionService: WhmcsConnectionService,
private readonly dataTransformer: WhmcsDataTransformer, private readonly subscriptionTransformer: SubscriptionTransformerService,
private readonly cacheService: WhmcsCacheService private readonly cacheService: WhmcsCacheService
) {} ) {}
@ -69,7 +69,7 @@ export class WhmcsSubscriptionService {
const subscriptions = response.products.product const subscriptions = response.products.product
.map(whmcsProduct => { .map(whmcsProduct => {
try { try {
return this.dataTransformer.transformSubscription(whmcsProduct); return this.subscriptionTransformer.transformSubscription(whmcsProduct);
} catch (error) { } catch (error) {
this.logger.error(`Failed to transform subscription ${whmcsProduct.id}`, { this.logger.error(`Failed to transform subscription ${whmcsProduct.id}`, {
error: getErrorMessage(error), error: getErrorMessage(error),

View File

@ -75,7 +75,8 @@ export class InvoiceTransformerService {
const message = DataUtils.toErrorMessage(error); const message = DataUtils.toErrorMessage(error);
this.logger.error(`Failed to transform invoice ${invoiceId}`, { this.logger.error(`Failed to transform invoice ${invoiceId}`, {
error: message, 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}`); throw new Error(`Failed to transform invoice: ${message}`);
} }
@ -85,53 +86,35 @@ export class InvoiceTransformerService {
* Transform WHMCS invoice items to our standard format * Transform WHMCS invoice items to our standard format
*/ */
private transformInvoiceItems(items: WhmcsInvoiceItems | undefined): InvoiceItem[] { private transformInvoiceItems(items: WhmcsInvoiceItems | undefined): InvoiceItem[] {
if (!items) return []; if (!items || !items.item) return [];
try { // WHMCS API returns either an array or single item
const itemsArray = Array.isArray(items.item) ? items.item : [items.item]; const itemsArray = Array.isArray(items.item) ? items.item : [items.item];
return itemsArray return itemsArray.map(item => this.transformSingleInvoiceItem(item));
.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 [];
}
} }
/** /**
* Transform a single invoice item * Transform a single invoice item using exact WHMCS API structure
*/ */
private transformSingleInvoiceItem(item: Record<string, unknown>): InvoiceItem | null { private transformSingleInvoiceItem(item: WhmcsInvoiceItems['item'][0]): InvoiceItem {
try { const transformedItem: InvoiceItem = {
const transformedItem: InvoiceItem = { id: item.id,
id: DataUtils.safeNumber(item.id, 0), description: item.description,
description: DataUtils.safeString(item.description, "Unknown Item"), amount: DataUtils.parseAmount(item.amount),
amount: DataUtils.parseAmount(item.amount), quantity: 1, // WHMCS invoice items don't have quantity field, always 1
quantity: DataUtils.safeNumber(item.qty, 1), type: item.type,
type: DataUtils.safeString(item.type, "Unknown"), };
};
// Add service ID if available // Add service ID from relid field
if (item.relid) { if (item.relid) {
transformedItem.serviceId = DataUtils.safeNumber(item.relid); transformedItem.serviceId = 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;
} }
// Note: taxed field exists in WHMCS but not in our domain schema
// Tax information is handled at the invoice level
return transformedItem;
} }
/** /**

View File

@ -21,16 +21,13 @@ export class PaymentTransformerService {
transformPaymentGateway(whmcsGateway: WhmcsPaymentGateway): PaymentGateway { transformPaymentGateway(whmcsGateway: WhmcsPaymentGateway): PaymentGateway {
try { try {
const gateway: PaymentGateway = { const gateway: PaymentGateway = {
name: DataUtils.safeString(whmcsGateway.name), name: whmcsGateway.name,
displayName: DataUtils.safeString( displayName: whmcsGateway.display_name,
whmcsGateway.display_name || whmcsGateway.name, type: whmcsGateway.type,
whmcsGateway.name isActive: whmcsGateway.active,
), acceptsCreditCards: whmcsGateway.accepts_credit_cards || false,
type: this.normalizeGatewayType(DataUtils.safeString(whmcsGateway.type, "manual")), acceptsBankAccount: whmcsGateway.accepts_bank_account || false,
isActive: DataUtils.safeBoolean(whmcsGateway.active), supportsTokenization: whmcsGateway.supports_tokenization || false,
acceptsCreditCards: DataUtils.safeBoolean(whmcsGateway.accepts_credit_cards),
acceptsBankAccount: DataUtils.safeBoolean(whmcsGateway.accepts_bank_account),
supportsTokenization: DataUtils.safeBoolean(whmcsGateway.supports_tokenization),
}; };
if (!this.validator.validatePaymentGateway(gateway)) { if (!this.validator.validatePaymentGateway(gateway)) {
@ -51,119 +48,44 @@ export class PaymentTransformerService {
* Transform WHMCS payment method to shared PaymentMethod interface * Transform WHMCS payment method to shared PaymentMethod interface
*/ */
transformPaymentMethod(whmcsPayMethod: WhmcsPaymentMethod): PaymentMethod { transformPaymentMethod(whmcsPayMethod: WhmcsPaymentMethod): PaymentMethod {
try { const transformed: PaymentMethod = {
// Handle field name variations between different WHMCS API responses id: whmcsPayMethod.id,
const payMethodId = whmcsPayMethod.id || whmcsPayMethod.paymethodid; type: whmcsPayMethod.type,
const gatewayName = whmcsPayMethod.gateway_name || whmcsPayMethod.gateway || whmcsPayMethod.type; description: whmcsPayMethod.description,
const lastFour = whmcsPayMethod.last_four || whmcsPayMethod.lastfour || whmcsPayMethod.last4; gatewayName: whmcsPayMethod.gateway_name || "",
const cardType = whmcsPayMethod.card_type || whmcsPayMethod.cardtype || whmcsPayMethod.brand; isDefault: false, // Default value, can be set by calling service
const expiryDate = whmcsPayMethod.expiry_date || whmcsPayMethod.expdate || whmcsPayMethod.expiry; };
if (!payMethodId) { // Add credit card specific fields
throw new Error("Payment method ID is required"); if (whmcsPayMethod.last_four) {
} transformed.lastFour = whmcsPayMethod.last_four;
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;
} }
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 * Normalize expiry date to MM/YY format
@ -205,7 +127,7 @@ export class PaymentTransformerService {
const transformed = this.transformPaymentMethod(whmcsPayMethod); const transformed = this.transformPaymentMethod(whmcsPayMethod);
results.push(transformed); results.push(transformed);
} catch (error) { } catch (error) {
const payMethodId = whmcsPayMethod?.id || whmcsPayMethod?.paymethodid || "unknown"; const payMethodId = whmcsPayMethod?.id || "unknown";
const message = DataUtils.toErrorMessage(error); const message = DataUtils.toErrorMessage(error);
errors.push(`Payment method ${payMethodId}: ${message}`); errors.push(`Payment method ${payMethodId}: ${message}`);
} }

View File

@ -34,7 +34,7 @@ export class SubscriptionTransformerService {
// Safety override: If we have no recurring amount but have first payment, treat as one-time // Safety override: If we have no recurring amount but have first payment, treat as one-time
if (recurringAmount === 0 && firstPaymentAmount > 0) { if (recurringAmount === 0 && firstPaymentAmount > 0) {
normalizedCycle = "One Time"; normalizedCycle = "Monthly"; // Default to Monthly for one-time payments
} }
const subscription: Subscription = { const subscription: Subscription = {
@ -73,7 +73,9 @@ export class SubscriptionTransformerService {
const message = DataUtils.toErrorMessage(error); const message = DataUtils.toErrorMessage(error);
this.logger.error(`Failed to transform subscription ${whmcsProduct.id}`, { this.logger.error(`Failed to transform subscription ${whmcsProduct.id}`, {
error: message, 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}`); throw new Error(`Failed to transform subscription: ${message}`);
} }
@ -113,7 +115,7 @@ export class SubscriptionTransformerService {
} catch (error) { } catch (error) {
this.logger.warn("Failed to extract custom fields", { this.logger.warn("Failed to extract custom fields", {
error: DataUtils.toErrorMessage(error), error: DataUtils.toErrorMessage(error),
customFieldsData: DataUtils.sanitizeForLog(customFields as unknown as Record<string, unknown>), customFieldsCount: customFields?.length || 0,
}); });
return undefined; return undefined;
} }

View File

@ -5,6 +5,7 @@ import {
PaymentMethod, PaymentMethod,
PaymentGateway, PaymentGateway,
} from "@customer-portal/domain"; } from "@customer-portal/domain";
import type { WhmcsInvoice, WhmcsProduct } from "../../types/whmcs-api.types";
/** /**
* Service for validating transformed data objects * Service for validating transformed data objects
@ -77,41 +78,26 @@ export class TransformationValidator {
/** /**
* Validate invoice items array * 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; if (!Array.isArray(items)) return false;
return items.every(item => { return items.every(item => {
if (!item || typeof item !== "object") return false; return Boolean(item.description && item.amount && item.id);
const requiredFields = ["description", "amount"];
return requiredFields.every(field => {
const value = (item as Record<string, unknown>)[field];
return value !== undefined && value !== null;
});
}); });
} }
/** /**
* Validate that required WHMCS data is present * Validate that required WHMCS invoice data is present
*/ */
validateWhmcsInvoiceData(whmcsInvoice: unknown): boolean { validateWhmcsInvoiceData(whmcsInvoice: WhmcsInvoice): boolean {
if (!whmcsInvoice || typeof whmcsInvoice !== "object") return false; return Boolean(whmcsInvoice.invoiceid || whmcsInvoice.id);
const invoice = whmcsInvoice as Record<string, unknown>;
const invoiceId = invoice.invoiceid || invoice.id;
return Boolean(invoiceId);
} }
/** /**
* Validate that required WHMCS product data is present * Validate that required WHMCS product data is present
*/ */
validateWhmcsProductData(whmcsProduct: unknown): boolean { validateWhmcsProductData(whmcsProduct: WhmcsProduct): boolean {
if (!whmcsProduct || typeof whmcsProduct !== "object") return false; return Boolean(whmcsProduct.id);
const product = whmcsProduct as Record<string, unknown>;
return Boolean(product.id);
} }
/** /**
@ -127,7 +113,7 @@ export class TransformationValidator {
/** /**
* Validate amount is a valid number * Validate amount is a valid number
*/ */
validateAmount(amount: unknown): boolean { validateAmount(amount: string | number): boolean {
if (typeof amount === "number") { if (typeof amount === "number") {
return !isNaN(amount) && isFinite(amount); return !isNaN(amount) && isFinite(amount);
} }
@ -143,8 +129,8 @@ export class TransformationValidator {
/** /**
* Validate date string format * Validate date string format
*/ */
validateDateString(dateStr: unknown): boolean { validateDateString(dateStr: string): boolean {
if (!dateStr || typeof dateStr !== "string") return false; if (!dateStr) return false;
const date = new Date(dateStr); const date = new Date(dateStr);
return !isNaN(date.getTime()); return !isNaN(date.getTime());

View File

@ -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);
}
}

View File

@ -333,7 +333,92 @@ export interface WhmcsPaymentGatewaysResponse {
totalresults: number; 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;
}

View File

@ -1,6 +1,5 @@
import { Module } from "@nestjs/common"; import { Module } from "@nestjs/common";
import { ConfigModule } from "@nestjs/config"; import { ConfigModule } from "@nestjs/config";
import { WhmcsDataTransformer } from "./transformers/whmcs-data.transformer";
import { WhmcsCacheService } from "./cache/whmcs-cache.service"; import { WhmcsCacheService } from "./cache/whmcs-cache.service";
import { WhmcsService } from "./whmcs.service"; import { WhmcsService } from "./whmcs.service";
import { WhmcsConnectionService } from "./services/whmcs-connection.service"; import { WhmcsConnectionService } from "./services/whmcs-connection.service";
@ -26,8 +25,6 @@ import { WhmcsApiMethodsService } from "./connection/services/whmcs-api-methods.
@Module({ @Module({
imports: [ConfigModule], imports: [ConfigModule],
providers: [ providers: [
// Legacy transformer (now facade)
WhmcsDataTransformer,
// New modular transformer services // New modular transformer services
WhmcsTransformerOrchestratorService, WhmcsTransformerOrchestratorService,
InvoiceTransformerService, InvoiceTransformerService,
@ -56,7 +53,6 @@ import { WhmcsApiMethodsService } from "./connection/services/whmcs-api-methods.
WhmcsService, WhmcsService,
WhmcsConnectionService, WhmcsConnectionService,
WhmcsConnectionOrchestratorService, WhmcsConnectionOrchestratorService,
WhmcsDataTransformer,
WhmcsTransformerOrchestratorService, WhmcsTransformerOrchestratorService,
WhmcsCacheService, WhmcsCacheService,
WhmcsOrderService, WhmcsOrderService,

View File

@ -279,12 +279,6 @@ export class WhmcsService {
return this.paymentService.getProducts() as Promise<WhmcsCatalogProductsResponse>; 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) // SSO OPERATIONS (delegate to SsoService)
@ -353,4 +347,52 @@ export class WhmcsService {
getOrderService(): WhmcsOrderService { getOrderService(): WhmcsOrderService {
return this.orderService; 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);
}
} }

View File

@ -3,8 +3,6 @@ export { InvoicesOrchestratorService } from "./services/invoices-orchestrator.se
// Individual services // Individual services
export { InvoiceRetrievalService } from "./services/invoice-retrieval.service"; 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"; export { InvoiceHealthService } from "./services/invoice-health.service";
// Validators // Validators

View File

@ -167,7 +167,9 @@ export class InvoicesController {
throw new BadRequestException("Invoice ID must be a positive number"); 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") @Post(":id/sso-link")
@ -199,7 +201,7 @@ export class InvoicesController {
throw new BadRequestException('Target must be "view", "download", or "pay"'); 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") @Post(":id/payment-link")
@ -239,11 +241,11 @@ export class InvoicesController {
throw new BadRequestException("Payment method ID must be a positive number"); throw new BadRequestException("Payment method ID must be a positive number");
} }
return this.invoicesService.createPaymentSsoLink( return this.invoicesService.createInvoicePaymentLink(
req.user.id, req.user.id,
invoiceId, invoiceId,
paymentMethodIdNum, gatewayName || "stripe",
gatewayName "/"
); );
} }

View File

@ -6,8 +6,6 @@ import { MappingsModule } from "@bff/modules/id-mappings/mappings.module";
// New modular invoice services // New modular invoice services
import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service"; import { InvoicesOrchestratorService } from "./services/invoices-orchestrator.service";
import { InvoiceRetrievalService } from "./services/invoice-retrieval.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 { InvoiceHealthService } from "./services/invoice-health.service";
import { InvoiceValidatorService } from "./validators/invoice-validator.service"; import { InvoiceValidatorService } from "./validators/invoice-validator.service";
@ -20,8 +18,6 @@ import { InvoiceValidatorService } from "./validators/invoice-validator.service"
// New modular services // New modular services
InvoicesOrchestratorService, InvoicesOrchestratorService,
InvoiceRetrievalService, InvoiceRetrievalService,
InvoiceOperationsService,
PaymentMethodsService,
InvoiceHealthService, InvoiceHealthService,
InvoiceValidatorService, InvoiceValidatorService,
], ],

View File

@ -32,8 +32,6 @@ export class OrderValidator {
this.logger.debug( this.logger.debug(
{ {
bodyType: typeof rawBody, bodyType: typeof rawBody,
hasOrderType: !!(rawBody as Record<string, unknown>)?.orderType,
hasSkus: !!(rawBody as Record<string, unknown>)?.skus,
}, },
"Starting Zod request format validation" "Starting Zod request format validation"
); );
@ -108,7 +106,7 @@ export class OrderValidator {
*/ */
async validatePaymentMethod(userId: string, whmcsClientId: number): Promise<void> { async validatePaymentMethod(userId: string, whmcsClientId: number): Promise<void> {
try { 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) { if (!Array.isArray(pay?.paymethods) || pay.paymethods.length === 0) {
this.logger.warn({ userId }, "No WHMCS payment method on file"); this.logger.warn({ userId }, "No WHMCS payment method on file");
throw new BadRequestException("A payment method is required before ordering"); 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> { async validateInternetDuplication(userId: string, whmcsClientId: number): Promise<void> {
try { try {
const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId }); const products = await this.whmcs.getClientsProducts({ clientid: whmcsClientId });
const existing = (products?.products?.product || []) as Array<{ const existing = products?.products?.product || [];
groupname?: string; const hasInternet = existing.some(product =>
}>; (product.groupname || "")
const hasInternet = existing.some(p =>
String(p.groupname ?? "")
.toLowerCase() .toLowerCase()
.includes("internet") .includes("internet")
); );

View File

@ -24,8 +24,11 @@ export class SimTopUpService {
* Pricing: 1GB = 500 JPY * Pricing: 1GB = 500 JPY
*/ */
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> { async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
let account: string = "";
try { try {
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId); const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
account = validation.account;
// Validate quota amount // Validate quota amount
if (request.quotaMb <= 0 || request.quotaMb > 100000) { if (request.quotaMb <= 0 || request.quotaMb > 100000) {
@ -98,7 +101,7 @@ export class SimTopUpService {
}); });
// Cancel the invoice since payment failed // 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}`); throw new BadRequestException(`SIM top-up failed: ${paymentResult.error}`);
} }
@ -138,7 +141,7 @@ export class SimTopUpService {
await this.handleFreebitFailureAfterPayment( await this.handleFreebitFailureAfterPayment(
freebitError, freebitError,
invoice, invoice,
paymentResult.transactionId, paymentResult.transactionId || "unknown",
userId, userId,
subscriptionId, subscriptionId,
account, account,

View File

@ -87,11 +87,22 @@ export class SimOrderActivationService {
await this.freebit.activateEsimAccountNew({ await this.freebit.activateEsimAccountNew({
account: req.msisdn, account: req.msisdn,
eid: req.eid!, eid: req.eid!,
planCode: req.planSku, planSku: req.planSku,
contractLine: "5G", contractLine: "5G",
shipDate: req.activationType === "Scheduled" ? req.scheduledAt : undefined, shipDate: req.activationType === "Scheduled" ? req.scheduledAt : undefined,
mnp: req.mnp 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, : undefined,
identity: req.mnp identity: req.mnp
? { ? {