From 9fafd227b9ad5416a0992b2d3480117c72cf1bdb Mon Sep 17 00:00:00 2001 From: barsa Date: Thu, 25 Sep 2025 16:23:24 +0900 Subject: [PATCH] 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. --- .../services/freebit-client.service.ts | 2 +- .../salesforce/events/pubsub.subscriber.ts | 52 ++-- .../salesforce/types/pubsub-events.types.ts | 40 +++ .../services/whmcs-api-methods.service.ts | 62 +++++ .../whmcs-connection-orchestrator.service.ts | 42 ++++ .../services/whmcs-connection.service.ts | 57 +++-- .../whmcs/services/whmcs-invoice.service.ts | 233 +++++++++++++++++- .../whmcs/services/whmcs-order.service.ts | 8 +- .../whmcs/services/whmcs-payment.service.ts | 18 +- .../services/whmcs-subscription.service.ts | 6 +- .../services/invoice-transformer.service.ts | 65 ++--- .../services/payment-transformer.service.ts | 160 +++--------- .../subscription-transformer.service.ts | 8 +- .../validators/transformation-validator.ts | 36 +-- .../transformers/whmcs-data.transformer.ts | 76 ------ .../whmcs/types/whmcs-api.types.ts | 85 +++++++ .../src/integrations/whmcs/whmcs.module.ts | 4 - .../src/integrations/whmcs/whmcs.service.ts | 54 +++- apps/bff/src/modules/invoices/index.ts | 2 - .../modules/invoices/invoices.controller.ts | 12 +- .../src/modules/invoices/invoices.module.ts | 4 - .../services/order-validator.service.ts | 12 +- .../services/sim-topup.service.ts | 9 +- .../sim-order-activation.service.ts | 15 +- 24 files changed, 677 insertions(+), 385 deletions(-) create mode 100644 apps/bff/src/integrations/salesforce/types/pubsub-events.types.ts delete mode 100644 apps/bff/src/integrations/whmcs/transformers/whmcs-data.transformer.ts diff --git a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts index a20e8166..dd4d847b 100644 --- a/apps/bff/src/integrations/freebit/services/freebit-client.service.ts +++ b/apps/bff/src/integrations/freebit/services/freebit-client.service.ts @@ -24,7 +24,7 @@ export class FreebitClientService { */ async makeAuthenticatedRequest< TResponse extends FreebitResponseBase, - TPayload extends Record, + TPayload extends object, >(endpoint: string, payload: TPayload): Promise { const authKey = await this.authService.getAuthKey(); const config = this.authService.getConfig(); diff --git a/apps/bff/src/integrations/salesforce/events/pubsub.subscriber.ts b/apps/bff/src/integrations/salesforce/events/pubsub.subscriber.ts index 62ee9b9d..49d99004 100644 --- a/apps/bff/src/integrations/salesforce/events/pubsub.subscriber.ts +++ b/apps/bff/src/integrations/salesforce/events/pubsub.subscriber.ts @@ -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; 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; + 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 | undefined => { - const p = event["payload"]; - if (typeof p === "object" && p !== null) { - return p as Record; - } - 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; - const details = typeof maybeObj["details"] === "string" ? maybeObj["details"] : ""; - const metadata = (maybeObj["metadata"] || {}) as Record; - 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 | 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, diff --git a/apps/bff/src/integrations/salesforce/types/pubsub-events.types.ts b/apps/bff/src/integrations/salesforce/types/pubsub-events.types.ts new file mode 100644 index 00000000..d4db9c62 --- /dev/null +++ b/apps/bff/src/integrations/salesforce/types/pubsub-events.types.ts @@ -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; +} diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts index 537f556a..bb3d9c44 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-api-methods.service.ts @@ -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 { + return this.makeRequest("CreateInvoice", params); + } + + async updateInvoice(params: WhmcsUpdateInvoiceParams): Promise { + return this.makeRequest("UpdateInvoice", params); + } + + async capturePayment(params: WhmcsCapturePaymentParams): Promise { + return this.makeRequest("CapturePayment", params); + } + + // ========================================== + // ORDER API METHODS (Used by order services) + // ========================================== + + async addOrder(params: Record) { + return this.makeRequest("AddOrder", params); + } + + async getOrders(params: Record = {}) { + 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 diff --git a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts index 574f7f99..15f3b076 100644 --- a/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts +++ b/apps/bff/src/integrations/whmcs/connection/services/whmcs-connection-orchestrator.service.ts @@ -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) { + return this.apiMethods.addOrder(params); + } + + async getOrders(params: Record = {}) { + 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 diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-connection.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-connection.service.ts index 60778ca0..3d510b5d 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-connection.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-connection.service.ts @@ -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 { + 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 { - return this.orchestrator.addPaymentMethod(params); - } - async getPaymentGateways(): Promise { return this.orchestrator.getPaymentGateways(); } - async capturePayment(params: WhmcsCapturePaymentParams): Promise { - return this.orchestrator.capturePayment(params); + // Legacy method name for backward compatibility + async getPayMethods(params: WhmcsGetPayMethodsParams): Promise { + return this.getPaymentMethods(params); } - async addCredit(params: WhmcsAddCreditParams): Promise { - return this.orchestrator.addCredit(params); + async getProducts(): Promise { + return this.orchestrator.getProducts() as Promise; } - async addInvoicePayment( - params: WhmcsAddInvoicePaymentParams - ): Promise { - return this.orchestrator.addInvoicePayment(params); + async addOrder(params: Record) { + return this.orchestrator.addOrder(params); + } + + async getOrders(params: Record = {}) { + 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 diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index 29062553..d35a566e 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -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."; + } } diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts index 3b3c4baf..bbdd0aa3 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-order.service.ts @@ -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; + const response = (await this.connection.acceptOrder(orderId)) as Record; if (response.result !== "success") { throw new Error( diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts index ae64d953..5f78e41a 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-payment.service.ts @@ -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 { 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): unknown { - return this.dataTransformer.transformProduct(whmcsProduct); - } /** * Normalize WHMCS SSO redirect URLs to absolute using configured base URL. diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts index 4faf1e6d..b9810f6d 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-subscription.service.ts @@ -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), diff --git a/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts index 3e0dd596..d727cd61 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/invoice-transformer.service.ts @@ -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), + 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), - }); - 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): 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; } /** diff --git a/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts index 128cf236..27222ee7 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/payment-transformer.service.ts @@ -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), - }); - 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}`); } diff --git a/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts b/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts index a9f2ada2..d8d6ad84 100644 --- a/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts +++ b/apps/bff/src/integrations/whmcs/transformers/services/subscription-transformer.service.ts @@ -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), + 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), + customFieldsCount: customFields?.length || 0, }); return undefined; } diff --git a/apps/bff/src/integrations/whmcs/transformers/validators/transformation-validator.ts b/apps/bff/src/integrations/whmcs/transformers/validators/transformation-validator.ts index e1bbe7d3..c9a2e789 100644 --- a/apps/bff/src/integrations/whmcs/transformers/validators/transformation-validator.ts +++ b/apps/bff/src/integrations/whmcs/transformers/validators/transformation-validator.ts @@ -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)[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; - 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; - - 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()); diff --git a/apps/bff/src/integrations/whmcs/transformers/whmcs-data.transformer.ts b/apps/bff/src/integrations/whmcs/transformers/whmcs-data.transformer.ts deleted file mode 100644 index a36868ae..00000000 --- a/apps/bff/src/integrations/whmcs/transformers/whmcs-data.transformer.ts +++ /dev/null @@ -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); - } -} \ No newline at end of file diff --git a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts index 8ba3372c..d82e5e0f 100644 --- a/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts +++ b/apps/bff/src/integrations/whmcs/types/whmcs-api.types.ts @@ -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; +} diff --git a/apps/bff/src/integrations/whmcs/whmcs.module.ts b/apps/bff/src/integrations/whmcs/whmcs.module.ts index 45261537..b36b8286 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.module.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.module.ts @@ -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, diff --git a/apps/bff/src/integrations/whmcs/whmcs.service.ts b/apps/bff/src/integrations/whmcs/whmcs.service.ts index 19345ce7..46e86a5d 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.service.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.service.ts @@ -279,12 +279,6 @@ export class WhmcsService { return this.paymentService.getProducts() as Promise; } - /** - * 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); + } } diff --git a/apps/bff/src/modules/invoices/index.ts b/apps/bff/src/modules/invoices/index.ts index f2b251a5..b0f1c8c0 100644 --- a/apps/bff/src/modules/invoices/index.ts +++ b/apps/bff/src/modules/invoices/index.ts @@ -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 diff --git a/apps/bff/src/modules/invoices/invoices.controller.ts b/apps/bff/src/modules/invoices/invoices.controller.ts index e7a40182..42b4f33a 100644 --- a/apps/bff/src/modules/invoices/invoices.controller.ts +++ b/apps/bff/src/modules/invoices/invoices.controller.ts @@ -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", + "/" ); } diff --git a/apps/bff/src/modules/invoices/invoices.module.ts b/apps/bff/src/modules/invoices/invoices.module.ts index 43bf6555..ab41646b 100644 --- a/apps/bff/src/modules/invoices/invoices.module.ts +++ b/apps/bff/src/modules/invoices/invoices.module.ts @@ -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, ], diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts index 613f5728..5d6b4e32 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -32,8 +32,6 @@ export class OrderValidator { this.logger.debug( { bodyType: typeof rawBody, - hasOrderType: !!(rawBody as Record)?.orderType, - hasSkus: !!(rawBody as Record)?.skus, }, "Starting Zod request format validation" ); @@ -108,7 +106,7 @@ export class OrderValidator { */ async validatePaymentMethod(userId: string, whmcsClientId: number): Promise { 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 { 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") ); diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts index 07386a3a..c55d50c3 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts @@ -24,8 +24,11 @@ export class SimTopUpService { * Pricing: 1GB = 500 JPY */ async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise { + 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, diff --git a/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts b/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts index cdef012f..9a2860f3 100644 --- a/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts @@ -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 ? {