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<
TResponse extends FreebitResponseBase,
TPayload extends Record<string, unknown>,
TPayload extends object,
>(endpoint: string, payload: TPayload): Promise<TResponse> {
const authKey = await this.authService.getAuthKey();
const config = this.authService.getConfig();

View File

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

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,
WhmcsPayMethodsResponse,
WhmcsPaymentGatewaysResponse,
WhmcsCreateInvoiceResponse,
WhmcsUpdateInvoiceResponse,
WhmcsCapturePaymentResponse,
WhmcsGetInvoicesParams,
WhmcsGetClientsProductsParams,
WhmcsCreateSsoTokenParams,
WhmcsValidateLoginParams,
WhmcsAddClientParams,
WhmcsGetPayMethodsParams,
WhmcsCreateInvoiceParams,
WhmcsUpdateInvoiceParams,
WhmcsCapturePaymentParams,
} from "../../types/whmcs-api.types";
import { WhmcsHttpClientService } from "./whmcs-http-client.service";
import { WhmcsConfigService } from "../config/whmcs-config.service";
@ -136,6 +142,62 @@ export class WhmcsApiMethodsService {
return this.makeRequest("GetPaymentMethods", {});
}
async createInvoice(params: WhmcsCreateInvoiceParams): Promise<WhmcsCreateInvoiceResponse> {
return this.makeRequest("CreateInvoice", params);
}
async updateInvoice(params: WhmcsUpdateInvoiceParams): Promise<WhmcsUpdateInvoiceResponse> {
return this.makeRequest("UpdateInvoice", params);
}
async capturePayment(params: WhmcsCapturePaymentParams): Promise<WhmcsCapturePaymentResponse> {
return this.makeRequest("CapturePayment", params);
}
// ==========================================
// ORDER API METHODS (Used by order services)
// ==========================================
async addOrder(params: Record<string, unknown>) {
return this.makeRequest("AddOrder", params);
}
async getOrders(params: Record<string, unknown> = {}) {
return this.makeRequest("GetOrders", params);
}
// ==========================================
// ADMIN API METHODS (require admin auth)
// ==========================================
async acceptOrder(orderId: number): Promise<{ result: string }> {
if (!this.configService.hasAdminAuth()) {
throw new Error("Admin authentication required for AcceptOrder");
}
return this.makeRequest<{ result: string }>(
"AcceptOrder",
{
orderid: orderId.toString(),
autosetup: true,
sendemail: false
},
{ useAdminAuth: true }
);
}
async cancelOrder(orderId: number): Promise<{ result: string }> {
if (!this.configService.hasAdminAuth()) {
throw new Error("Admin authentication required for CancelOrder");
}
return this.makeRequest<{ result: string }>(
"CancelOrder",
{ orderid: orderId },
{ useAdminAuth: true }
);
}
// ==========================================
// SSO API METHODS

View File

@ -14,6 +14,9 @@ import type {
WhmcsGetClientsProductsParams,
WhmcsGetPayMethodsParams,
WhmcsCreateSsoTokenParams,
WhmcsCreateInvoiceParams,
WhmcsUpdateInvoiceParams,
WhmcsCapturePaymentParams,
} from "../../types/whmcs-api.types";
import type {
WhmcsRequestOptions,
@ -138,6 +141,45 @@ export class WhmcsConnectionOrchestratorService implements OnModuleInit {
return this.apiMethods.getInvoice(invoiceId);
}
async createInvoice(params: WhmcsCreateInvoiceParams) {
return this.apiMethods.createInvoice(params);
}
async updateInvoice(params: WhmcsUpdateInvoiceParams) {
return this.apiMethods.updateInvoice(params);
}
async capturePayment(params: WhmcsCapturePaymentParams) {
return this.apiMethods.capturePayment(params);
}
// ==========================================
// ORDER OPERATIONS (Used by order services)
// ==========================================
async addOrder(params: Record<string, unknown>) {
return this.apiMethods.addOrder(params);
}
async getOrders(params: Record<string, unknown> = {}) {
return this.apiMethods.getOrders(params);
}
// ==========================================
// ADMIN OPERATIONS (Used by order services)
// ==========================================
/**
* Accept an order (requires admin authentication)
*/
async acceptOrder(orderId: number) {
return this.apiMethods.acceptOrder(orderId);
}
async cancelOrder(orderId: number) {
return this.apiMethods.cancelOrder(orderId);
}
// ==========================================
// PRODUCT/SUBSCRIPTION API METHODS

View File

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

View File

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

View File

@ -95,13 +95,7 @@ export class WhmcsOrderService {
try {
// Call WHMCS AcceptOrder API
const response = (await this.connection.acceptOrder({
orderid: orderId.toString(),
// Ensure module provisioning is executed even if product config is different
autosetup: true,
// Suppress customer emails to remain consistent with earlier noemail flag
sendemail: false,
})) as Record<string, unknown>;
const response = (await this.connection.acceptOrder(orderId)) as Record<string, unknown>;
if (response.result !== "success") {
throw new Error(

View File

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

View File

@ -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),

View File

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

View File

@ -21,16 +21,13 @@ export class PaymentTransformerService {
transformPaymentGateway(whmcsGateway: WhmcsPaymentGateway): PaymentGateway {
try {
const gateway: PaymentGateway = {
name: DataUtils.safeString(whmcsGateway.name),
displayName: DataUtils.safeString(
whmcsGateway.display_name || whmcsGateway.name,
whmcsGateway.name
),
type: this.normalizeGatewayType(DataUtils.safeString(whmcsGateway.type, "manual")),
isActive: DataUtils.safeBoolean(whmcsGateway.active),
acceptsCreditCards: DataUtils.safeBoolean(whmcsGateway.accepts_credit_cards),
acceptsBankAccount: DataUtils.safeBoolean(whmcsGateway.accepts_bank_account),
supportsTokenization: DataUtils.safeBoolean(whmcsGateway.supports_tokenization),
name: whmcsGateway.name,
displayName: whmcsGateway.display_name,
type: whmcsGateway.type,
isActive: whmcsGateway.active,
acceptsCreditCards: whmcsGateway.accepts_credit_cards || false,
acceptsBankAccount: whmcsGateway.accepts_bank_account || false,
supportsTokenization: whmcsGateway.supports_tokenization || false,
};
if (!this.validator.validatePaymentGateway(gateway)) {
@ -51,119 +48,44 @@ export class PaymentTransformerService {
* Transform WHMCS payment method to shared PaymentMethod interface
*/
transformPaymentMethod(whmcsPayMethod: WhmcsPaymentMethod): PaymentMethod {
try {
// Handle field name variations between different WHMCS API responses
const payMethodId = whmcsPayMethod.id || whmcsPayMethod.paymethodid;
const gatewayName = whmcsPayMethod.gateway_name || whmcsPayMethod.gateway || whmcsPayMethod.type;
const lastFour = whmcsPayMethod.last_four || whmcsPayMethod.lastfour || whmcsPayMethod.last4;
const cardType = whmcsPayMethod.card_type || whmcsPayMethod.cardtype || whmcsPayMethod.brand;
const expiryDate = whmcsPayMethod.expiry_date || whmcsPayMethod.expdate || whmcsPayMethod.expiry;
const transformed: PaymentMethod = {
id: whmcsPayMethod.id,
type: whmcsPayMethod.type,
description: whmcsPayMethod.description,
gatewayName: whmcsPayMethod.gateway_name || "",
isDefault: false, // Default value, can be set by calling service
};
if (!payMethodId) {
throw new Error("Payment method ID is required");
}
const transformed: PaymentMethod = {
id: DataUtils.safeNumber(payMethodId, 0),
type: this.normalizePaymentType(gatewayName),
description: this.buildPaymentDescription(whmcsPayMethod),
gatewayName: DataUtils.safeString(gatewayName),
isDefault: DataUtils.safeBoolean(whmcsPayMethod.is_default || whmcsPayMethod.default),
};
// Add credit card specific fields
if (lastFour) {
transformed.lastFour = DataUtils.safeString(lastFour);
}
if (cardType) {
transformed.ccType = DataUtils.safeString(cardType);
}
if (expiryDate) {
transformed.expiryDate = this.normalizeExpiryDate(expiryDate);
}
// Add bank account specific fields
if (whmcsPayMethod.account_type) {
transformed.accountType = DataUtils.safeString(whmcsPayMethod.account_type);
}
// Note: routingNumber is not part of the PaymentMethod interface
// This would need to be added to the interface if needed
if (!this.validator.validatePaymentMethod(transformed)) {
throw new Error("Transformed payment method failed validation");
}
return transformed;
} catch (error) {
this.logger.error("Failed to transform payment method", {
error: DataUtils.toErrorMessage(error),
whmcsData: DataUtils.sanitizeForLog(whmcsPayMethod as unknown as Record<string, unknown>),
});
throw error;
// Add credit card specific fields
if (whmcsPayMethod.last_four) {
transformed.lastFour = whmcsPayMethod.last_four;
}
if (whmcsPayMethod.cc_type) {
transformed.ccType = whmcsPayMethod.cc_type;
}
if (whmcsPayMethod.expiry_date) {
transformed.expiryDate = this.normalizeExpiryDate(whmcsPayMethod.expiry_date);
}
// Add bank account specific fields
if (whmcsPayMethod.account_type) {
transformed.accountType = whmcsPayMethod.account_type;
}
if (whmcsPayMethod.bank_name) {
transformed.bankName = whmcsPayMethod.bank_name;
}
if (!this.validator.validatePaymentMethod(transformed)) {
throw new Error("Transformed payment method failed validation");
}
return transformed;
}
/**
* Build a human-readable description for the payment method
*/
private buildPaymentDescription(whmcsPayMethod: WhmcsPaymentMethod): string {
const gatewayName = whmcsPayMethod.gateway_name || whmcsPayMethod.gateway || whmcsPayMethod.type;
const lastFour = whmcsPayMethod.last_four || whmcsPayMethod.lastfour || whmcsPayMethod.last4;
const cardType = whmcsPayMethod.card_type || whmcsPayMethod.cardtype || whmcsPayMethod.brand;
// For credit cards
if (lastFour && cardType) {
return `${cardType} ending in ${lastFour}`;
}
if (lastFour) {
return `Card ending in ${lastFour}`;
}
// For bank accounts
if (whmcsPayMethod.account_type && whmcsPayMethod.routing_number) {
return `${whmcsPayMethod.account_type} account`;
}
// Fallback to gateway name
if (gatewayName) {
return `${gatewayName} payment method`;
}
return "Payment method";
}
/**
* Normalize payment type from gateway name
*/
private normalizePaymentType(gatewayName: string): string {
if (!gatewayName) return "unknown";
const gateway = gatewayName.toLowerCase();
// Credit card gateways
if (gateway.includes("stripe") || gateway.includes("paypal") ||
gateway.includes("square") || gateway.includes("authorize")) {
return "credit_card";
}
// Bank transfer gateways
if (gateway.includes("bank") || gateway.includes("ach") ||
gateway.includes("wire") || gateway.includes("transfer")) {
return "bank_account";
}
// Digital wallets
if (gateway.includes("paypal") || gateway.includes("apple") ||
gateway.includes("google") || gateway.includes("amazon")) {
return "digital_wallet";
}
return gatewayName;
}
/**
* Normalize expiry date to MM/YY format
@ -205,7 +127,7 @@ export class PaymentTransformerService {
const transformed = this.transformPaymentMethod(whmcsPayMethod);
results.push(transformed);
} catch (error) {
const payMethodId = whmcsPayMethod?.id || whmcsPayMethod?.paymethodid || "unknown";
const payMethodId = whmcsPayMethod?.id || "unknown";
const message = DataUtils.toErrorMessage(error);
errors.push(`Payment method ${payMethodId}: ${message}`);
}

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

View File

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

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;
}
// ==========================================
// 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 { 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,

View File

@ -279,12 +279,6 @@ export class WhmcsService {
return this.paymentService.getProducts() as Promise<WhmcsCatalogProductsResponse>;
}
/**
* Transform product data (delegate to transformer)
*/
transformProduct(whmcsProduct: WhmcsCatalogProductsResponse["products"]["product"][0]): unknown {
return this.paymentService.transformProduct(whmcsProduct);
}
// ==========================================
// SSO OPERATIONS (delegate to SsoService)
@ -353,4 +347,52 @@ export class WhmcsService {
getOrderService(): WhmcsOrderService {
return this.orderService;
}
// ==========================================
// INVOICE CREATION AND PAYMENT OPERATIONS (Used by SIM/Order services)
// ==========================================
/**
* Create a new invoice for a client
*/
async createInvoice(params: {
clientId: number;
description: string;
amount: number;
currency?: string;
dueDate?: Date;
notes?: string;
}): Promise<{ id: number; number: string; total: number; status: string }> {
return this.invoiceService.createInvoice(params);
}
/**
* Update an existing invoice
*/
async updateInvoice(params: {
invoiceId: number;
status?:
| "Draft"
| "Unpaid"
| "Paid"
| "Cancelled"
| "Refunded"
| "Collections"
| "Payment Pending";
dueDate?: Date;
notes?: string;
}): Promise<{ success: boolean; message?: string }> {
return this.invoiceService.updateInvoice(params);
}
/**
* Capture payment for an invoice
*/
async capturePayment(params: {
invoiceId: number;
amount: number;
currency?: string;
}): Promise<{ success: boolean; transactionId?: string; error?: string }> {
return this.invoiceService.capturePayment(params);
}
}

View File

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

View File

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

View File

@ -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,
],

View File

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

View File

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

View File

@ -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
? {