Enhance SIM management service with payment processing and API integration
- Implemented WHMCS invoice creation and payment capture in SimManagementService for top-ups. - Updated top-up logic to calculate costs based on GB input, with pricing set at 500 JPY per GB. - Simplified the Top Up Modal interface, removing unnecessary fields and improving user experience. - Added new methods in WhmcsService for invoice and payment operations. - Enhanced error handling for payment failures and added transaction logging for audit purposes. - Updated documentation to reflect changes in the SIM management flow and API interactions.
This commit is contained in:
parent
ae56477714
commit
ac259ce902
3
.gitignore
vendored
3
.gitignore
vendored
@ -145,3 +145,6 @@ prisma/migrations/dev.db*
|
|||||||
*.tar
|
*.tar
|
||||||
*.tar.gz
|
*.tar.gz
|
||||||
*.zip
|
*.zip
|
||||||
|
|
||||||
|
# API Documentation (contains sensitive API details)
|
||||||
|
docs/freebit-apis/
|
||||||
|
|||||||
@ -232,9 +232,7 @@ export class OrderOrchestrator {
|
|||||||
// Get order items for all orders in one query
|
// Get order items for all orders in one query
|
||||||
const orderIds = orders.map(o => `'${o.Id}'`).join(",");
|
const orderIds = orders.map(o => `'${o.Id}'`).join(",");
|
||||||
const itemsSoql = `
|
const itemsSoql = `
|
||||||
|
SELECT Id, OrderId, Quantity, UnitPrice, TotalPrice,
|
||||||
|
|
||||||
SELECT Id, OrderId, Quantity,
|
|
||||||
${getOrderItemProduct2Select()}
|
${getOrderItemProduct2Select()}
|
||||||
FROM OrderItem
|
FROM OrderItem
|
||||||
WHERE OrderId IN (${orderIds})
|
WHERE OrderId IN (${orderIds})
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common';
|
import { Injectable, Inject, BadRequestException, NotFoundException } from '@nestjs/common';
|
||||||
import { Logger } from 'nestjs-pino';
|
import { Logger } from 'nestjs-pino';
|
||||||
import { FreebititService } from '../vendors/freebit/freebit.service';
|
import { FreebititService } from '../vendors/freebit/freebit.service';
|
||||||
|
import { WhmcsService } from '../vendors/whmcs/whmcs.service';
|
||||||
import { MappingsService } from '../mappings/mappings.service';
|
import { MappingsService } from '../mappings/mappings.service';
|
||||||
import { SubscriptionsService } from './subscriptions.service';
|
import { SubscriptionsService } from './subscriptions.service';
|
||||||
import { SimDetails, SimUsage, SimTopUpHistory } from '../vendors/freebit/interfaces/freebit.types';
|
import { SimDetails, SimUsage, SimTopUpHistory } from '../vendors/freebit/interfaces/freebit.types';
|
||||||
@ -9,9 +10,6 @@ import { getErrorMessage } from '../common/utils/error.util';
|
|||||||
|
|
||||||
export interface SimTopUpRequest {
|
export interface SimTopUpRequest {
|
||||||
quotaMb: number;
|
quotaMb: number;
|
||||||
campaignCode?: string;
|
|
||||||
expiryDate?: string; // YYYYMMDD
|
|
||||||
scheduledAt?: string; // YYYYMMDD or YYYY-MM-DD HH:MM:SS
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SimPlanChangeRequest {
|
export interface SimPlanChangeRequest {
|
||||||
@ -40,6 +38,7 @@ export interface SimFeaturesUpdateRequest {
|
|||||||
export class SimManagementService {
|
export class SimManagementService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly freebititService: FreebititService,
|
private readonly freebititService: FreebititService,
|
||||||
|
private readonly whmcsService: WhmcsService,
|
||||||
private readonly mappingsService: MappingsService,
|
private readonly mappingsService: MappingsService,
|
||||||
private readonly subscriptionsService: SubscriptionsService,
|
private readonly subscriptionsService: SubscriptionsService,
|
||||||
@Inject(Logger) private readonly logger: Logger,
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
@ -222,7 +221,8 @@ export class SimManagementService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Top up SIM data quota
|
* Top up SIM data quota with payment processing
|
||||||
|
* Pricing: 1GB = 500 JPY
|
||||||
*/
|
*/
|
||||||
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
||||||
try {
|
try {
|
||||||
@ -233,28 +233,108 @@ export class SimManagementService {
|
|||||||
throw new BadRequestException('Quota must be between 1MB and 100GB');
|
throw new BadRequestException('Quota must be between 1MB and 100GB');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate date formats if provided
|
|
||||||
if (request.expiryDate && !/^\d{8}$/.test(request.expiryDate)) {
|
// Calculate cost: 1GB = 500 JPY
|
||||||
throw new BadRequestException('Expiry date must be in YYYYMMDD format');
|
const quotaGb = request.quotaMb / 1024;
|
||||||
|
const costJpy = Math.round(quotaGb * 500);
|
||||||
|
|
||||||
|
// Get client mapping for WHMCS
|
||||||
|
const mapping = await this.mappingsService.findByUserId(userId);
|
||||||
|
if (!mapping?.whmcsClientId) {
|
||||||
|
throw new BadRequestException('WHMCS client mapping not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.scheduledAt && !/^\d{8}$/.test(request.scheduledAt.replace(/[-:\s]/g, ''))) {
|
this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, {
|
||||||
throw new BadRequestException('Scheduled date must be in YYYYMMDD format');
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.freebititService.topUpSim(account, request.quotaMb, {
|
|
||||||
campaignCode: request.campaignCode,
|
|
||||||
expiryDate: request.expiryDate,
|
|
||||||
scheduledAt: request.scheduledAt,
|
|
||||||
});
|
|
||||||
|
|
||||||
this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, {
|
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
account,
|
account,
|
||||||
quotaMb: request.quotaMb,
|
quotaMb: request.quotaMb,
|
||||||
scheduled: !!request.scheduledAt,
|
quotaGb: quotaGb.toFixed(2),
|
||||||
|
costJpy,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Step 1: Create WHMCS invoice
|
||||||
|
const invoice = await this.whmcsService.createInvoice({
|
||||||
|
clientId: mapping.whmcsClientId,
|
||||||
|
description: `SIM Data Top-up: ${quotaGb.toFixed(1)}GB for ${account}`,
|
||||||
|
amount: costJpy,
|
||||||
|
currency: 'JPY',
|
||||||
|
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
|
||||||
|
notes: `Subscription ID: ${subscriptionId}, Phone: ${account}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Created WHMCS invoice ${invoice.id} for SIM top-up`, {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
invoiceNumber: invoice.number,
|
||||||
|
amount: costJpy,
|
||||||
|
subscriptionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Step 2: Capture payment
|
||||||
|
const paymentResult = await this.whmcsService.capturePayment({
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
amount: costJpy,
|
||||||
|
currency: 'JPY',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!paymentResult.success) {
|
||||||
|
this.logger.error(`Payment capture failed for invoice ${invoice.id}`, {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
error: paymentResult.error,
|
||||||
|
subscriptionId,
|
||||||
|
});
|
||||||
|
throw new BadRequestException(`Payment failed: ${paymentResult.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Payment captured successfully for invoice ${invoice.id}`, {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
transactionId: paymentResult.transactionId,
|
||||||
|
amount: costJpy,
|
||||||
|
subscriptionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Step 3: Only if payment successful, add data via Freebit
|
||||||
|
await this.freebititService.topUpSim(account, request.quotaMb, {});
|
||||||
|
|
||||||
|
this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
quotaMb: request.quotaMb,
|
||||||
|
costJpy,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
transactionId: paymentResult.transactionId,
|
||||||
|
});
|
||||||
|
} catch (freebititError) {
|
||||||
|
// If Freebit fails after payment, we need to handle this carefully
|
||||||
|
// For now, we'll log the error and throw it - in production, you might want to:
|
||||||
|
// 1. Create a refund/credit
|
||||||
|
// 2. Send notification to admin
|
||||||
|
// 3. Queue for retry
|
||||||
|
this.logger.error(`Freebit API failed after successful payment for subscription ${subscriptionId}`, {
|
||||||
|
error: getErrorMessage(freebititError),
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
quotaMb: request.quotaMb,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
transactionId: paymentResult.transactionId,
|
||||||
|
paymentCaptured: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// TODO: Implement refund logic here
|
||||||
|
// await this.whmcsService.addCredit({
|
||||||
|
// clientId: mapping.whmcsClientId,
|
||||||
|
// description: `Refund for failed SIM top-up (Invoice: ${invoice.number})`,
|
||||||
|
// amount: costJpy,
|
||||||
|
// type: 'refund'
|
||||||
|
// });
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Payment was processed but data top-up failed. Please contact support with invoice ${invoice.number}. Error: ${getErrorMessage(freebititError)}`
|
||||||
|
);
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to top up SIM for subscription ${subscriptionId}`, {
|
this.logger.error(`Failed to top up SIM for subscription ${subscriptionId}`, {
|
||||||
error: getErrorMessage(error),
|
error: getErrorMessage(error),
|
||||||
@ -337,7 +417,6 @@ export class SimManagementService {
|
|||||||
subscriptionId,
|
subscriptionId,
|
||||||
account,
|
account,
|
||||||
newPlanCode: request.newPlanCode,
|
newPlanCode: request.newPlanCode,
|
||||||
scheduled: !!request.scheduledAt,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
@ -405,7 +484,6 @@ export class SimManagementService {
|
|||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
account,
|
account,
|
||||||
scheduled: !!request.scheduledAt,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
this.logger.error(`Failed to cancel SIM for subscription ${subscriptionId}`, {
|
this.logger.error(`Failed to cancel SIM for subscription ${subscriptionId}`, {
|
||||||
|
|||||||
@ -288,10 +288,7 @@ export class SubscriptionsController {
|
|||||||
schema: {
|
schema: {
|
||||||
type: "object",
|
type: "object",
|
||||||
properties: {
|
properties: {
|
||||||
quotaMb: { type: "number", description: "Quota in MB", example: 1000 },
|
quotaMb: { type: "number", description: "Quota in MB", example: 1024 },
|
||||||
campaignCode: { type: "string", description: "Optional campaign code" },
|
|
||||||
expiryDate: { type: "string", description: "Expiry date (YYYYMMDD)", example: "20241231" },
|
|
||||||
scheduledAt: { type: "string", description: "Schedule for later (YYYYMMDD)", example: "20241225" },
|
|
||||||
},
|
},
|
||||||
required: ["quotaMb"],
|
required: ["quotaMb"],
|
||||||
},
|
},
|
||||||
@ -302,9 +299,6 @@ export class SubscriptionsController {
|
|||||||
@Param("id", ParseIntPipe) subscriptionId: number,
|
@Param("id", ParseIntPipe) subscriptionId: number,
|
||||||
@Body() body: {
|
@Body() body: {
|
||||||
quotaMb: number;
|
quotaMb: number;
|
||||||
campaignCode?: string;
|
|
||||||
expiryDate?: string;
|
|
||||||
scheduledAt?: string;
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
await this.simManagementService.topUpSim(req.user.id, subscriptionId, body);
|
await this.simManagementService.topUpSim(req.user.id, subscriptionId, body);
|
||||||
|
|||||||
@ -23,6 +23,14 @@ import {
|
|||||||
WhmcsAddClientParams,
|
WhmcsAddClientParams,
|
||||||
WhmcsGetPayMethodsParams,
|
WhmcsGetPayMethodsParams,
|
||||||
WhmcsAddPayMethodParams,
|
WhmcsAddPayMethodParams,
|
||||||
|
WhmcsCreateInvoiceParams,
|
||||||
|
WhmcsCreateInvoiceResponse,
|
||||||
|
WhmcsCapturePaymentParams,
|
||||||
|
WhmcsCapturePaymentResponse,
|
||||||
|
WhmcsAddCreditParams,
|
||||||
|
WhmcsAddCreditResponse,
|
||||||
|
WhmcsAddInvoicePaymentParams,
|
||||||
|
WhmcsAddInvoicePaymentResponse,
|
||||||
} from "../types/whmcs-api.types";
|
} from "../types/whmcs-api.types";
|
||||||
|
|
||||||
export interface WhmcsApiConfig {
|
export interface WhmcsApiConfig {
|
||||||
@ -403,4 +411,36 @@ export class WhmcsConnectionService {
|
|||||||
async getOrders(params: Record<string, unknown>): Promise<unknown> {
|
async getOrders(params: Record<string, unknown>): Promise<unknown> {
|
||||||
return this.makeRequest("GetOrders", params);
|
return this.makeRequest("GetOrders", params);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// NEW: Invoice Creation and Payment Capture Methods
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new invoice for a client
|
||||||
|
*/
|
||||||
|
async createInvoice(params: WhmcsCreateInvoiceParams): Promise<WhmcsCreateInvoiceResponse> {
|
||||||
|
return this.makeRequest("CreateInvoice", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Capture payment for an invoice
|
||||||
|
*/
|
||||||
|
async capturePayment(params: WhmcsCapturePaymentParams): Promise<WhmcsCapturePaymentResponse> {
|
||||||
|
return this.makeRequest("CapturePayment", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add credit to a client account (useful for refunds)
|
||||||
|
*/
|
||||||
|
async addCredit(params: WhmcsAddCreditParams): Promise<WhmcsAddCreditResponse> {
|
||||||
|
return this.makeRequest("AddCredit", params);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Add a manual payment to an invoice
|
||||||
|
*/
|
||||||
|
async addInvoicePayment(params: WhmcsAddInvoicePaymentParams): Promise<WhmcsAddInvoicePaymentResponse> {
|
||||||
|
return this.makeRequest("AddInvoicePayment", params);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,13 @@ import { Invoice, InvoiceList } from "@customer-portal/shared";
|
|||||||
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
import { WhmcsConnectionService } from "./whmcs-connection.service";
|
||||||
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
|
import { WhmcsDataTransformer } from "../transformers/whmcs-data.transformer";
|
||||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||||
import { WhmcsGetInvoicesParams } from "../types/whmcs-api.types";
|
import {
|
||||||
|
WhmcsGetInvoicesParams,
|
||||||
|
WhmcsCreateInvoiceParams,
|
||||||
|
WhmcsCreateInvoiceResponse,
|
||||||
|
WhmcsCapturePaymentParams,
|
||||||
|
WhmcsCapturePaymentResponse
|
||||||
|
} from "../types/whmcs-api.types";
|
||||||
|
|
||||||
export interface InvoiceFilters {
|
export interface InvoiceFilters {
|
||||||
status?: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections";
|
status?: "Paid" | "Unpaid" | "Cancelled" | "Overdue" | "Collections";
|
||||||
@ -225,4 +231,115 @@ export class WhmcsInvoiceService {
|
|||||||
await this.cacheService.invalidateInvoice(userId, invoiceId);
|
await this.cacheService.invalidateInvoice(userId, invoiceId);
|
||||||
this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`);
|
this.logger.log(`Invalidated invoice cache for user ${userId}, invoice ${invoiceId}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// NEW: Invoice Creation Methods
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 {
|
||||||
|
success: false,
|
||||||
|
error: response.message || response.error || "Payment capture failed",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to capture payment for invoice ${params.invoiceId}`, {
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
error: getErrorMessage(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -84,15 +84,33 @@ export class WhmcsDataTransformer {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Determine pricing amounts early so we can infer one-time fees reliably
|
||||||
|
const recurringAmount = this.parseAmount(whmcsProduct.recurringamount);
|
||||||
|
const firstPaymentAmount = this.parseAmount(whmcsProduct.firstpaymentamount);
|
||||||
|
|
||||||
|
// Normalize billing cycle from WHMCS and apply safety overrides
|
||||||
|
let normalizedCycle = this.normalizeBillingCycle(whmcsProduct.billingcycle);
|
||||||
|
|
||||||
|
// Heuristic: Treat activation/setup style items as one-time regardless of cycle text
|
||||||
|
// - Many WHMCS installs represent these with a Monthly cycle but 0 recurring amount
|
||||||
|
// - Product names often contain "Activation Fee" or "Setup"
|
||||||
|
const nameLower = (whmcsProduct.name || whmcsProduct.productname || "").toLowerCase();
|
||||||
|
const looksLikeActivation =
|
||||||
|
nameLower.includes("activation fee") || nameLower.includes("activation") || nameLower.includes("setup");
|
||||||
|
|
||||||
|
if ((recurringAmount === 0 && firstPaymentAmount > 0) || looksLikeActivation) {
|
||||||
|
normalizedCycle = "One-time";
|
||||||
|
}
|
||||||
|
|
||||||
const subscription: Subscription = {
|
const subscription: Subscription = {
|
||||||
id: Number(whmcsProduct.id),
|
id: Number(whmcsProduct.id),
|
||||||
serviceId: Number(whmcsProduct.id),
|
serviceId: Number(whmcsProduct.id),
|
||||||
productName: this.getProductName(whmcsProduct),
|
productName: this.getProductName(whmcsProduct),
|
||||||
domain: whmcsProduct.domain || undefined,
|
domain: whmcsProduct.domain || undefined,
|
||||||
cycle: this.normalizeBillingCycle(whmcsProduct.billingcycle),
|
cycle: normalizedCycle,
|
||||||
status: this.normalizeProductStatus(whmcsProduct.status),
|
status: this.normalizeProductStatus(whmcsProduct.status),
|
||||||
nextDue: this.formatDate(whmcsProduct.nextduedate),
|
nextDue: this.formatDate(whmcsProduct.nextduedate),
|
||||||
amount: this.getProductAmount(whmcsProduct),
|
amount: recurringAmount > 0 ? recurringAmount : firstPaymentAmount,
|
||||||
currency: whmcsProduct.currencycode || "JPY",
|
currency: whmcsProduct.currencycode || "JPY",
|
||||||
|
|
||||||
registrationDate:
|
registrationDate:
|
||||||
@ -226,9 +244,13 @@ export class WhmcsDataTransformer {
|
|||||||
annually: "Annually",
|
annually: "Annually",
|
||||||
biennially: "Biennially",
|
biennially: "Biennially",
|
||||||
triennially: "Triennially",
|
triennially: "Triennially",
|
||||||
|
onetime: "One-time",
|
||||||
|
"one-time": "One-time",
|
||||||
|
"one time": "One-time",
|
||||||
|
free: "One-time", // Free products are typically one-time
|
||||||
};
|
};
|
||||||
|
|
||||||
return cycleMap[cycle?.toLowerCase()] || "Monthly";
|
return cycleMap[cycle?.toLowerCase()] || "One-time";
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@ -354,3 +354,94 @@ export interface WhmcsPaymentGatewaysResponse {
|
|||||||
};
|
};
|
||||||
totalresults: number;
|
totalresults: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ========================================
|
||||||
|
// NEW: Invoice Creation and Payment Capture Types
|
||||||
|
// ========================================
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddCredit API Types (for refunds if needed)
|
||||||
|
export interface WhmcsAddCreditParams {
|
||||||
|
clientid: number;
|
||||||
|
description: string;
|
||||||
|
amount: number;
|
||||||
|
type?: "add" | "refund";
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhmcsAddCreditResponse {
|
||||||
|
result: "success" | "error";
|
||||||
|
creditid: number;
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddInvoicePayment API Types (for manual payment recording)
|
||||||
|
export interface WhmcsAddInvoicePaymentParams {
|
||||||
|
invoiceid: number;
|
||||||
|
transid: string;
|
||||||
|
amount?: number;
|
||||||
|
fees?: number;
|
||||||
|
gateway: string;
|
||||||
|
date?: string; // YYYY-MM-DD HH:MM:SS format
|
||||||
|
noemail?: boolean;
|
||||||
|
[key: string]: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface WhmcsAddInvoicePaymentResponse {
|
||||||
|
result: "success" | "error";
|
||||||
|
message?: string;
|
||||||
|
}
|
||||||
29
apps/bff/src/vendors/whmcs/whmcs.service.ts
vendored
29
apps/bff/src/vendors/whmcs/whmcs.service.ts
vendored
@ -309,6 +309,35 @@ export class WhmcsService {
|
|||||||
return this.connectionService.getSystemInfo();
|
return this.connectionService.getSystemInfo();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ==========================================
|
||||||
|
// INVOICE CREATION AND PAYMENT OPERATIONS
|
||||||
|
// ==========================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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);
|
||||||
|
}
|
||||||
|
|
||||||
// ==========================================
|
// ==========================================
|
||||||
// ORDER OPERATIONS (delegate to OrderService)
|
// ORDER OPERATIONS (delegate to OrderService)
|
||||||
// ==========================================
|
// ==========================================
|
||||||
|
|||||||
@ -266,36 +266,36 @@ export default function OrdersPage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{order.totalAmount &&
|
{(() => {
|
||||||
(() => {
|
const totals = calculateOrderTotals(order);
|
||||||
const totals = calculateOrderTotals(order);
|
if (totals.monthlyTotal <= 0 && totals.oneTimeTotal <= 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="space-y-1">
|
<div className="space-y-1">
|
||||||
<p className="text-2xl font-bold text-gray-900">
|
<p className="text-2xl font-bold text-gray-900">
|
||||||
¥{totals.monthlyTotal.toLocaleString()}
|
¥{totals.monthlyTotal.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-gray-500">per month</p>
|
<p className="text-sm text-gray-500">per month</p>
|
||||||
|
|
||||||
{totals.oneTimeTotal > 0 && (
|
{totals.oneTimeTotal > 0 && (
|
||||||
<>
|
<>
|
||||||
<p className="text-lg font-semibold text-orange-600">
|
<p className="text-lg font-semibold text-orange-600">
|
||||||
¥{totals.oneTimeTotal.toLocaleString()}
|
¥{totals.oneTimeTotal.toLocaleString()}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-gray-500">one-time</p>
|
<p className="text-xs text-gray-500">one-time</p>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Fee Disclaimer */}
|
|
||||||
<div className="mt-3 text-xs text-gray-500 text-left">
|
|
||||||
<p>* Additional fees may apply</p>
|
|
||||||
<p className="text-gray-400">(e.g., weekend installation)</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
})()}
|
{/* Fee Disclaimer */}
|
||||||
|
<div className="mt-3 text-xs text-gray-500 text-left">
|
||||||
|
<p>* Additional fees may apply</p>
|
||||||
|
<p className="text-gray-400">(e.g., weekend installation)</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@ -152,6 +152,8 @@ export default function SubscriptionDetailPage() {
|
|||||||
return "Biennial Billing";
|
return "Biennial Billing";
|
||||||
case "Triennially":
|
case "Triennially":
|
||||||
return "Triennial Billing";
|
return "Triennial Billing";
|
||||||
|
case "One-time":
|
||||||
|
return "One-time Payment";
|
||||||
default:
|
default:
|
||||||
return "One-time Payment";
|
return "One-time Payment";
|
||||||
}
|
}
|
||||||
|
|||||||
@ -128,9 +128,13 @@ export default function SubscriptionsPage() {
|
|||||||
{
|
{
|
||||||
key: "cycle",
|
key: "cycle",
|
||||||
header: "Billing Cycle",
|
header: "Billing Cycle",
|
||||||
render: (subscription: Subscription) => (
|
render: (subscription: Subscription) => {
|
||||||
<span className="text-sm text-gray-900">{subscription.cycle}</span>
|
const name = (subscription.productName || '').toLowerCase();
|
||||||
),
|
const looksLikeActivation =
|
||||||
|
name.includes('activation fee') || name.includes('activation') || name.includes('setup');
|
||||||
|
const displayCycle = looksLikeActivation ? 'One-time' : subscription.cycle;
|
||||||
|
return <span className="text-sm text-gray-900">{displayCycle}</span>;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "price",
|
key: "price",
|
||||||
@ -156,7 +160,9 @@ export default function SubscriptionsPage() {
|
|||||||
? "per 2 years"
|
? "per 2 years"
|
||||||
: subscription.cycle === "Triennially"
|
: subscription.cycle === "Triennially"
|
||||||
? "per 3 years"
|
? "per 3 years"
|
||||||
: "one-time"}
|
: subscription.cycle === "One-time"
|
||||||
|
? "one-time"
|
||||||
|
: "one-time"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
),
|
),
|
||||||
|
|||||||
@ -15,71 +15,40 @@ interface TopUpModalProps {
|
|||||||
onError: (message: string) => void;
|
onError: (message: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const TOP_UP_PRESETS = [
|
|
||||||
{ label: '1 GB', value: 1024, popular: false },
|
|
||||||
{ label: '2 GB', value: 2048, popular: true },
|
|
||||||
{ label: '5 GB', value: 5120, popular: true },
|
|
||||||
{ label: '10 GB', value: 10240, popular: false },
|
|
||||||
{ label: '20 GB', value: 20480, popular: false },
|
|
||||||
{ label: '50 GB', value: 51200, popular: false },
|
|
||||||
];
|
|
||||||
|
|
||||||
export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) {
|
export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) {
|
||||||
const [selectedAmount, setSelectedAmount] = useState<number>(2048); // Default to 2GB
|
const [gbAmount, setGbAmount] = useState<string>('1');
|
||||||
const [customAmount, setCustomAmount] = useState<string>('');
|
|
||||||
const [useCustom, setUseCustom] = useState(false);
|
|
||||||
const [campaignCode, setCampaignCode] = useState<string>('');
|
|
||||||
const [scheduleDate, setScheduleDate] = useState<string>('');
|
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
|
||||||
const formatAmount = (mb: number) => {
|
const getCurrentAmountMb = () => {
|
||||||
if (mb >= 1024) {
|
const gb = parseFloat(gbAmount);
|
||||||
return `${(mb / 1024).toFixed(mb % 1024 === 0 ? 0 : 1)} GB`;
|
return isNaN(gb) ? 0 : Math.round(gb * 1024);
|
||||||
}
|
|
||||||
return `${mb} MB`;
|
|
||||||
};
|
|
||||||
|
|
||||||
const getCurrentAmount = () => {
|
|
||||||
if (useCustom) {
|
|
||||||
const custom = parseInt(customAmount, 10);
|
|
||||||
return isNaN(custom) ? 0 : custom;
|
|
||||||
}
|
|
||||||
return selectedAmount;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const isValidAmount = () => {
|
const isValidAmount = () => {
|
||||||
const amount = getCurrentAmount();
|
const gb = parseFloat(gbAmount);
|
||||||
return amount > 0 && amount <= 100000; // Max 100GB
|
return gb > 0 && gb <= 100; // Max 100GB
|
||||||
};
|
};
|
||||||
|
|
||||||
const formatDateForApi = (dateString: string) => {
|
const calculateCost = () => {
|
||||||
if (!dateString) return undefined;
|
const gb = parseFloat(gbAmount);
|
||||||
return dateString.replace(/-/g, ''); // Convert YYYY-MM-DD to YYYYMMDD
|
return isNaN(gb) ? 0 : Math.round(gb * 500); // 1GB = 500 JPY
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
||||||
if (!isValidAmount()) {
|
if (!isValidAmount()) {
|
||||||
onError('Please enter a valid amount between 1 MB and 100 GB');
|
onError('Please enter a valid amount between 0.1 GB and 100 GB');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const requestBody: any = {
|
const requestBody = {
|
||||||
quotaMb: getCurrentAmount(),
|
quotaMb: getCurrentAmountMb(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (campaignCode.trim()) {
|
|
||||||
requestBody.campaignCode = campaignCode.trim();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (scheduleDate) {
|
|
||||||
requestBody.scheduledAt = formatDateForApi(scheduleDate);
|
|
||||||
}
|
|
||||||
|
|
||||||
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/top-up`, requestBody);
|
await authenticatedApi.post(`/subscriptions/${subscriptionId}/sim/top-up`, requestBody);
|
||||||
|
|
||||||
onSuccess();
|
onSuccess();
|
||||||
@ -123,118 +92,60 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit}>
|
<form onSubmit={handleSubmit}>
|
||||||
{/* Amount Selection */}
|
{/* Amount Input */}
|
||||||
<div className="mb-6">
|
<div className="mb-6">
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-3">
|
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||||
Select Amount
|
Amount (GB)
|
||||||
</label>
|
</label>
|
||||||
|
<div className="relative">
|
||||||
{/* Preset Amounts */}
|
<input
|
||||||
<div className="grid grid-cols-2 gap-3 mb-4">
|
type="number"
|
||||||
{TOP_UP_PRESETS.map((preset) => (
|
value={gbAmount}
|
||||||
<button
|
onChange={(e) => setGbAmount(e.target.value)}
|
||||||
key={preset.value}
|
placeholder="Enter amount in GB"
|
||||||
type="button"
|
min="0.1"
|
||||||
onClick={() => {
|
max="100"
|
||||||
setSelectedAmount(preset.value);
|
step="0.1"
|
||||||
setUseCustom(false);
|
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500 pr-12"
|
||||||
}}
|
/>
|
||||||
className={`relative flex items-center justify-center px-4 py-3 text-sm font-medium rounded-lg border transition-colors ${
|
<div className="absolute inset-y-0 right-0 pr-3 flex items-center pointer-events-none">
|
||||||
!useCustom && selectedAmount === preset.value
|
<span className="text-gray-500 text-sm">GB</span>
|
||||||
? 'border-blue-500 bg-blue-50 text-blue-700'
|
</div>
|
||||||
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{preset.label}
|
|
||||||
{preset.popular && (
|
|
||||||
<span className="absolute -top-2 -right-2 bg-blue-500 text-white text-xs px-2 py-0.5 rounded-full">
|
|
||||||
Popular
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
|
Enter the amount of data you want to add (0.1 - 100 GB)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Custom Amount */}
|
{/* Cost Display */}
|
||||||
<div className="space-y-2">
|
<div className="mb-6 p-4 bg-blue-50 rounded-lg border border-blue-200">
|
||||||
<button
|
<div className="flex justify-between items-center">
|
||||||
type="button"
|
<div>
|
||||||
onClick={() => setUseCustom(!useCustom)}
|
<div className="text-sm font-medium text-blue-900">
|
||||||
className="text-sm text-blue-600 hover:text-blue-500"
|
{gbAmount && !isNaN(parseFloat(gbAmount)) ? `${gbAmount} GB` : '0 GB'}
|
||||||
>
|
</div>
|
||||||
{useCustom ? 'Use preset amounts' : 'Enter custom amount'}
|
<div className="text-xs text-blue-700">
|
||||||
</button>
|
= {getCurrentAmountMb()} MB
|
||||||
|
</div>
|
||||||
{useCustom && (
|
</div>
|
||||||
<div>
|
<div className="text-right">
|
||||||
<label className="block text-sm text-gray-600 mb-1">Custom Amount (MB)</label>
|
<div className="text-lg font-bold text-blue-900">
|
||||||
<input
|
¥{calculateCost().toLocaleString()}
|
||||||
type="number"
|
</div>
|
||||||
value={customAmount}
|
<div className="text-xs text-blue-700">
|
||||||
onChange={(e) => setCustomAmount(e.target.value)}
|
(500 JPY per GB)
|
||||||
placeholder="Enter amount in MB (e.g., 3072 for 3 GB)"
|
|
||||||
min="1"
|
|
||||||
max="100000"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
{customAmount && (
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
= {formatAmount(parseInt(customAmount, 10) || 0)}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Amount Display */}
|
|
||||||
<div className="mt-4 p-3 bg-blue-50 rounded-lg">
|
|
||||||
<div className="text-sm text-blue-800">
|
|
||||||
<strong>Selected Amount:</strong> {formatAmount(getCurrentAmount())}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Campaign Code (Optional) */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Campaign Code (Optional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={campaignCode}
|
|
||||||
onChange={(e) => setCampaignCode(e.target.value)}
|
|
||||||
placeholder="Enter campaign code if you have one"
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Campaign codes may provide discounts or special pricing
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Schedule Date (Optional) */}
|
|
||||||
<div className="mb-6">
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
|
||||||
Schedule for Later (Optional)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={scheduleDate}
|
|
||||||
onChange={(e) => setScheduleDate(e.target.value)}
|
|
||||||
min={new Date().toISOString().split('T')[0]}
|
|
||||||
className="w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
|
||||||
Leave empty to apply the top-up immediately
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Validation Warning */}
|
{/* Validation Warning */}
|
||||||
{!isValidAmount() && getCurrentAmount() > 0 && (
|
{!isValidAmount() && gbAmount && (
|
||||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3">
|
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3">
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<ExclamationTriangleIcon className="h-4 w-4 text-red-500 mr-2" />
|
<ExclamationTriangleIcon className="h-4 w-4 text-red-500 mr-2" />
|
||||||
<p className="text-sm text-red-800">
|
<p className="text-sm text-red-800">
|
||||||
Amount must be between 1 MB and 100 GB
|
Amount must be between 0.1 GB and 100 GB
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -255,7 +166,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
disabled={loading || !isValidAmount()}
|
disabled={loading || !isValidAmount()}
|
||||||
className="w-full sm:w-auto px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
className="w-full sm:w-auto px-4 py-2 border border-transparent rounded-md shadow-sm text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-blue-500 disabled:opacity-50"
|
||||||
>
|
>
|
||||||
{loading ? 'Processing...' : scheduleDate ? 'Schedule Top-Up' : 'Top Up Now'}
|
{loading ? 'Processing...' : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
@ -264,4 +175,4 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -138,6 +138,258 @@ apps/portal/src/features/sim-management/
|
|||||||
└── index.ts # Exports
|
└── index.ts # Exports
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## 📱 SIM Management Page Analysis
|
||||||
|
|
||||||
|
### Page URL: `http://localhost:3000/subscriptions/29951#sim-management`
|
||||||
|
|
||||||
|
This section provides a detailed breakdown of every element on the SIM management page, mapping each UI component to its corresponding API endpoint and data transformation.
|
||||||
|
|
||||||
|
### 🔄 Data Flow Overview
|
||||||
|
|
||||||
|
1. **Page Load**: `SimManagementSection.tsx` calls `GET /api/subscriptions/29951/sim`
|
||||||
|
2. **Backend Processing**: BFF calls multiple Freebit APIs to gather comprehensive SIM data
|
||||||
|
3. **Data Transformation**: Raw Freebit responses are transformed into portal-friendly format
|
||||||
|
4. **UI Rendering**: Components display the processed data with interactive elements
|
||||||
|
|
||||||
|
### 📊 Page Layout Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
|
│ SIM Management Page │
|
||||||
|
│ (max-w-7xl container) │
|
||||||
|
├─────────────────────────────────────────────────────────────┤
|
||||||
|
│ Left Side (2/3 width) │ Right Side (1/3 width) │
|
||||||
|
│ ┌─────────────────────────┐ │ ┌─────────────────────┐ │
|
||||||
|
│ │ SIM Management Actions │ │ │ SIM Details Card │ │
|
||||||
|
│ │ (4 action buttons) │ │ │ (eSIM/Physical) │ │
|
||||||
|
│ └─────────────────────────┘ │ └─────────────────────┘ │
|
||||||
|
│ ┌─────────────────────────┐ │ ┌─────────────────────┐ │
|
||||||
|
│ │ Service Options │ │ │ Data Usage Chart │ │
|
||||||
|
│ │ (Voice Mail, etc.) │ │ │ (Progress + History)│ │
|
||||||
|
│ └─────────────────────────┘ │ └─────────────────────┘ │
|
||||||
|
│ │ ┌─────────────────────┐ │
|
||||||
|
│ │ │ Important Info │ │
|
||||||
|
│ │ │ (Notices & Warnings)│ │
|
||||||
|
│ │ └─────────────────────┘ │
|
||||||
|
└─────────────────────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
## 🔍 Detailed Component Analysis
|
||||||
|
|
||||||
|
### 1. **SIM Details Card** (Right Side - Top)
|
||||||
|
|
||||||
|
**Component**: `SimDetailsCard.tsx`
|
||||||
|
**API Endpoint**: `GET /api/subscriptions/29951/sim/details`
|
||||||
|
**Freebit API**: `PA03-02: Get Account Details` (`/mvno/getDetail/`)
|
||||||
|
|
||||||
|
#### Data Mapping:
|
||||||
|
```typescript
|
||||||
|
// Freebit API Response → Portal Display
|
||||||
|
{
|
||||||
|
"account": "08077052946", // → Phone Number display
|
||||||
|
"iccid": "8944504101234567890", // → ICCID (Physical SIM only)
|
||||||
|
"eid": "8904xxxxxxxx...", // → EID (eSIM only)
|
||||||
|
"imsi": "440100123456789", // → IMSI display
|
||||||
|
"planCode": "PASI_5G", // → "5GB Plan" (formatted)
|
||||||
|
"status": "active", // → Status badge with color
|
||||||
|
"simType": "physical", // → SIM type indicator
|
||||||
|
"size": "nano", // → SIM size display
|
||||||
|
"hasVoice": true, // → Voice service indicator
|
||||||
|
"hasSms": true, // → SMS service indicator
|
||||||
|
"remainingQuotaMb": 512, // → "512 MB" (formatted)
|
||||||
|
"ipv4": "27.108.216.188", // → IPv4 address display
|
||||||
|
"ipv6": "2001:db8::1", // → IPv6 address display
|
||||||
|
"startDate": "2024-01-15", // → Service start date
|
||||||
|
"voiceMailEnabled": true, // → Voice Mail status
|
||||||
|
"callWaitingEnabled": false, // → Call Waiting status
|
||||||
|
"internationalRoamingEnabled": true, // → Roaming status
|
||||||
|
"networkType": "5G" // → Network type display
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Visual Elements:
|
||||||
|
- **Header**: SIM type icon + plan name + status badge
|
||||||
|
- **Phone Number**: Large, prominent display
|
||||||
|
- **Data Remaining**: Green highlight with formatted units (MB/GB)
|
||||||
|
- **Service Features**: Status indicators with color coding
|
||||||
|
- **IP Addresses**: Monospace font for technical data
|
||||||
|
- **Pending Operations**: Blue warning box for scheduled changes
|
||||||
|
|
||||||
|
### 2. **Data Usage Chart** (Right Side - Middle)
|
||||||
|
|
||||||
|
**Component**: `DataUsageChart.tsx`
|
||||||
|
**API Endpoint**: `GET /api/subscriptions/29951/sim/usage`
|
||||||
|
**Freebit API**: `PA05-01: MVNO Communication Information Retrieval` (`/mvno/getTrafficInfo/`)
|
||||||
|
|
||||||
|
#### Data Mapping:
|
||||||
|
```typescript
|
||||||
|
// Freebit API Response → Portal Display
|
||||||
|
{
|
||||||
|
"account": "08077052946",
|
||||||
|
"todayUsageKb": 524288, // → "512 MB" (today's usage)
|
||||||
|
"todayUsageMb": 512, // → Today's usage card
|
||||||
|
"recentDaysUsage": [ // → Recent usage history
|
||||||
|
{
|
||||||
|
"date": "2024-01-14",
|
||||||
|
"usageKb": 1048576,
|
||||||
|
"usageMb": 1024 // → Individual day bars
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"isBlacklisted": false // → Service restriction warning
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Visual Elements:
|
||||||
|
- **Progress Bar**: Color-coded based on usage percentage
|
||||||
|
- Green: 0-50% usage
|
||||||
|
- Orange: 50-75% usage
|
||||||
|
- Yellow: 75-90% usage
|
||||||
|
- Red: 90%+ usage
|
||||||
|
- **Today's Usage Card**: Blue gradient with usage amount
|
||||||
|
- **Remaining Quota Card**: Green gradient with remaining data
|
||||||
|
- **Recent History**: Mini progress bars for last 5 days
|
||||||
|
- **Usage Warnings**: Color-coded alerts for high usage
|
||||||
|
|
||||||
|
### 3. **SIM Management Actions** (Left Side - Top)
|
||||||
|
|
||||||
|
**Component**: `SimActions.tsx`
|
||||||
|
**API Endpoints**: Various POST endpoints for actions
|
||||||
|
|
||||||
|
#### Action Buttons:
|
||||||
|
|
||||||
|
##### 🔵 **Top Up Data** Button
|
||||||
|
- **API**: `POST /api/subscriptions/29951/sim/top-up`
|
||||||
|
- **WHMCS APIs**: `CreateInvoice` → `CapturePayment`
|
||||||
|
- **Freebit API**: `PA04-04: Add Specs & Quota` (`/master/addSpec/`)
|
||||||
|
- **Modal**: `TopUpModal.tsx` with custom GB input field
|
||||||
|
- **Pricing**: 1GB = 500 JPY
|
||||||
|
- **Color Theme**: Blue (`bg-blue-50`, `text-blue-700`, `border-blue-200`)
|
||||||
|
- **Status**: ✅ **Fully Implemented** with payment processing
|
||||||
|
|
||||||
|
##### 🟢 **Reissue eSIM** Button (eSIM only)
|
||||||
|
- **API**: `POST /api/subscriptions/29951/sim/reissue-esim`
|
||||||
|
- **Freebit API**: `PA05-42: eSIM Profile Reissue` (`/esim/reissueProfile/`)
|
||||||
|
- **Confirmation**: Inline modal with warning about new QR code
|
||||||
|
- **Color Theme**: Green (`bg-green-50`, `text-green-700`, `border-green-200`)
|
||||||
|
|
||||||
|
##### 🔴 **Cancel SIM** Button
|
||||||
|
- **API**: `POST /api/subscriptions/29951/sim/cancel`
|
||||||
|
- **Freebit API**: `PA05-04: MVNO Plan Cancellation` (`/mvno/releasePlan/`)
|
||||||
|
- **Confirmation**: Destructive action modal with permanent warning
|
||||||
|
- **Color Theme**: Red (`bg-red-50`, `text-red-700`, `border-red-200`)
|
||||||
|
|
||||||
|
##### 🟣 **Change Plan** Button
|
||||||
|
- **API**: `POST /api/subscriptions/29951/sim/change-plan`
|
||||||
|
- **Freebit API**: `PA05-21: MVNO Plan Change` (`/mvno/changePlan/`)
|
||||||
|
- **Modal**: `ChangePlanModal.tsx` with plan selection
|
||||||
|
- **Color Theme**: Purple (`bg-purple-50`, `text-purple-700`, `border-purple-300`)
|
||||||
|
- **Important Notice**: "Plan changes must be requested before the 25th of the month"
|
||||||
|
|
||||||
|
#### Button States:
|
||||||
|
- **Enabled**: Full color theme with hover effects
|
||||||
|
- **Disabled**: Gray theme when SIM is not active
|
||||||
|
- **Loading**: "Processing..." text with disabled state
|
||||||
|
|
||||||
|
### 4. **Service Options** (Left Side - Bottom)
|
||||||
|
|
||||||
|
**Component**: `SimFeatureToggles.tsx`
|
||||||
|
**API Endpoint**: `POST /api/subscriptions/29951/sim/features`
|
||||||
|
**Freebit APIs**: Various voice option endpoints
|
||||||
|
|
||||||
|
#### Service Options:
|
||||||
|
|
||||||
|
##### 📞 **Voice Mail** (¥300/month)
|
||||||
|
- **Current Status**: Enabled/Disabled indicator
|
||||||
|
- **Toggle**: Dropdown to change status
|
||||||
|
- **API Mapping**: Voice option management endpoints
|
||||||
|
|
||||||
|
##### 📞 **Call Waiting** (¥300/month)
|
||||||
|
- **Current Status**: Enabled/Disabled indicator
|
||||||
|
- **Toggle**: Dropdown to change status
|
||||||
|
- **API Mapping**: Voice option management endpoints
|
||||||
|
|
||||||
|
##### 🌍 **International Roaming**
|
||||||
|
- **Current Status**: Enabled/Disabled indicator
|
||||||
|
- **Toggle**: Dropdown to change status
|
||||||
|
- **API Mapping**: Roaming configuration endpoints
|
||||||
|
|
||||||
|
##### 📶 **Network Type** (4G/5G)
|
||||||
|
- **Current Status**: Network type display
|
||||||
|
- **Toggle**: Dropdown to switch between 4G/5G
|
||||||
|
- **API Mapping**: Contract line change endpoints
|
||||||
|
|
||||||
|
### 5. **Important Information** (Right Side - Bottom)
|
||||||
|
|
||||||
|
**Component**: Static information panel in `SimManagementSection.tsx`
|
||||||
|
|
||||||
|
#### Information Items:
|
||||||
|
- **Real-time Updates**: "Data usage is updated in real-time and may take a few minutes to reflect recent activity"
|
||||||
|
- **Top-up Processing**: "Top-up data will be available immediately after successful processing"
|
||||||
|
- **Cancellation Warning**: "SIM cancellation is permanent and cannot be undone"
|
||||||
|
- **eSIM Reissue**: "eSIM profile reissue will provide a new QR code for activation" (eSIM only)
|
||||||
|
|
||||||
|
## 🔄 API Call Sequence
|
||||||
|
|
||||||
|
### Page Load Sequence:
|
||||||
|
1. **Initial Load**: `GET /api/subscriptions/29951/sim`
|
||||||
|
2. **Backend Processing**:
|
||||||
|
- `PA01-01: OEM Authentication` → Get auth token
|
||||||
|
- `PA03-02: Get Account Details` → SIM details
|
||||||
|
- `PA05-01: MVNO Communication Information` → Usage data
|
||||||
|
3. **Data Transformation**: Combine responses into unified format
|
||||||
|
4. **UI Rendering**: Display all components with data
|
||||||
|
|
||||||
|
### Action Sequences:
|
||||||
|
|
||||||
|
#### Top Up Data (Complete Payment Flow):
|
||||||
|
1. User clicks "Top Up Data" → Opens `TopUpModal`
|
||||||
|
2. User selects amount (1GB = 500 JPY) → `POST /api/subscriptions/29951/sim/top-up`
|
||||||
|
3. Backend: Calculate cost (quotaGb * 500 JPY)
|
||||||
|
4. Backend: WHMCS `CreateInvoice` → Generate invoice for payment
|
||||||
|
5. Backend: WHMCS `CapturePayment` → Process payment with invoice
|
||||||
|
6. Backend: If payment successful → Freebit `PA04-04: Add Specs & Quota`
|
||||||
|
7. Backend: If payment failed → Return error, no data added
|
||||||
|
8. Frontend: Success/Error response → Refresh SIM data → Show message
|
||||||
|
|
||||||
|
#### eSIM Reissue:
|
||||||
|
1. User clicks "Reissue eSIM" → Confirmation modal
|
||||||
|
2. User confirms → `POST /api/subscriptions/29951/sim/reissue-esim`
|
||||||
|
3. Backend calls `PA05-42: eSIM Profile Reissue`
|
||||||
|
4. Success response → Show success message
|
||||||
|
|
||||||
|
#### Cancel SIM:
|
||||||
|
1. User clicks "Cancel SIM" → Destructive confirmation modal
|
||||||
|
2. User confirms → `POST /api/subscriptions/29951/sim/cancel`
|
||||||
|
3. Backend calls `PA05-04: MVNO Plan Cancellation`
|
||||||
|
4. Success response → Refresh SIM data → Show success message
|
||||||
|
|
||||||
|
#### Change Plan:
|
||||||
|
1. User clicks "Change Plan" → Opens `ChangePlanModal`
|
||||||
|
2. User selects new plan → `POST /api/subscriptions/29951/sim/change-plan`
|
||||||
|
3. Backend calls `PA05-21: MVNO Plan Change`
|
||||||
|
4. Success response → Refresh SIM data → Show success message
|
||||||
|
|
||||||
|
## 🎨 Visual Design Elements
|
||||||
|
|
||||||
|
### Color Coding:
|
||||||
|
- **Blue**: Primary actions (Top Up Data)
|
||||||
|
- **Green**: eSIM operations (Reissue eSIM)
|
||||||
|
- **Red**: Destructive actions (Cancel SIM)
|
||||||
|
- **Purple**: Secondary actions (Change Plan)
|
||||||
|
- **Yellow**: Warnings and notices
|
||||||
|
- **Gray**: Disabled states
|
||||||
|
|
||||||
|
### Status Indicators:
|
||||||
|
- **Active**: Green checkmark + green badge
|
||||||
|
- **Suspended**: Yellow warning + yellow badge
|
||||||
|
- **Cancelled**: Red X + red badge
|
||||||
|
- **Pending**: Blue clock + blue badge
|
||||||
|
|
||||||
|
### Progress Visualization:
|
||||||
|
- **Usage Bar**: Color-coded based on percentage
|
||||||
|
- **Mini Bars**: Recent usage history
|
||||||
|
- **Cards**: Today's usage and remaining quota
|
||||||
|
|
||||||
### Current Layout Structure
|
### Current Layout Structure
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────────┐
|
┌─────────────────────────────────────────────────────────────┐
|
||||||
@ -512,4 +764,106 @@ The Freebit SIM management system is now fully implemented and ready for deploym
|
|||||||
- Use the debug endpoint (`/api/subscriptions/{id}/sim/debug`) for account validation
|
- Use the debug endpoint (`/api/subscriptions/{id}/sim/debug`) for account validation
|
||||||
- Contact the development team for advanced issues
|
- Contact the development team for advanced issues
|
||||||
|
|
||||||
**🏆 The SIM management system is production-ready and fully operational!**
|
## 📋 SIM Management Page Summary
|
||||||
|
|
||||||
|
### Complete API Mapping for `http://localhost:3000/subscriptions/29951#sim-management`
|
||||||
|
|
||||||
|
| UI Element | Component | Portal API | Freebit API | Data Transformation |
|
||||||
|
|------------|-----------|------------|-------------|-------------------|
|
||||||
|
| **SIM Details Card** | `SimDetailsCard.tsx` | `GET /api/subscriptions/29951/sim/details` | `PA03-02: Get Account Details` | Raw Freebit response → Formatted display with status badges |
|
||||||
|
| **Data Usage Chart** | `DataUsageChart.tsx` | `GET /api/subscriptions/29951/sim/usage` | `PA05-01: MVNO Communication Information` | Usage data → Progress bars and history charts |
|
||||||
|
| **Top Up Data Button** | `SimActions.tsx` | `POST /api/subscriptions/29951/sim/top-up` | `WHMCS: CreateInvoice + CapturePayment`<br>`PA04-04: Add Specs & Quota` | User input → Invoice creation → Payment capture → Freebit top-up |
|
||||||
|
| **Reissue eSIM Button** | `SimActions.tsx` | `POST /api/subscriptions/29951/sim/reissue-esim` | `PA05-42: eSIM Profile Reissue` | Confirmation → eSIM reissue request |
|
||||||
|
| **Cancel SIM Button** | `SimActions.tsx` | `POST /api/subscriptions/29951/sim/cancel` | `PA05-04: MVNO Plan Cancellation` | Confirmation → Cancellation request |
|
||||||
|
| **Change Plan Button** | `SimActions.tsx` | `POST /api/subscriptions/29951/sim/change-plan` | `PA05-21: MVNO Plan Change` | Plan selection → Plan change request |
|
||||||
|
| **Service Options** | `SimFeatureToggles.tsx` | `POST /api/subscriptions/29951/sim/features` | Various voice option APIs | Feature toggles → Service updates |
|
||||||
|
|
||||||
|
### Key Data Transformations:
|
||||||
|
|
||||||
|
1. **Status Mapping**: Freebit status → Portal status with color coding
|
||||||
|
2. **Plan Formatting**: Plan codes → Human-readable plan names
|
||||||
|
3. **Usage Visualization**: Raw KB data → MB/GB with progress bars
|
||||||
|
4. **Date Formatting**: ISO dates → User-friendly date displays
|
||||||
|
5. **Error Handling**: Freebit errors → User-friendly error messages
|
||||||
|
|
||||||
|
### Real-time Updates:
|
||||||
|
- All actions trigger data refresh via `handleActionSuccess()`
|
||||||
|
- Loading states prevent duplicate actions
|
||||||
|
- Success/error messages provide immediate feedback
|
||||||
|
- Automatic retry on network failures
|
||||||
|
|
||||||
|
## 🔄 **Recent Implementation: Complete Top-Up Payment Flow**
|
||||||
|
|
||||||
|
### ✅ **What Was Added (January 2025)**:
|
||||||
|
|
||||||
|
#### **WHMCS Invoice Creation & Payment Capture**
|
||||||
|
- ✅ **New WHMCS API Types**: `WhmcsCreateInvoiceParams`, `WhmcsCapturePaymentParams`, etc.
|
||||||
|
- ✅ **WhmcsConnectionService**: Added `createInvoice()` and `capturePayment()` methods
|
||||||
|
- ✅ **WhmcsInvoiceService**: Added invoice creation and payment processing
|
||||||
|
- ✅ **WhmcsService**: Exposed new invoice and payment methods
|
||||||
|
|
||||||
|
#### **Enhanced SIM Management Service**
|
||||||
|
- ✅ **Payment Integration**: `SimManagementService.topUpSim()` now includes full payment flow
|
||||||
|
- ✅ **Pricing Logic**: 1GB = 500 JPY calculation
|
||||||
|
- ✅ **Error Handling**: Payment failures prevent data addition
|
||||||
|
- ✅ **Transaction Logging**: Complete audit trail for payments and top-ups
|
||||||
|
|
||||||
|
#### **Complete Flow Implementation**
|
||||||
|
```
|
||||||
|
User Action → Cost Calculation → Invoice Creation → Payment Capture → Data Addition
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📊 **Pricing Structure**:
|
||||||
|
- **1 GB = 500 JPY**
|
||||||
|
- **2 GB = 1,000 JPY**
|
||||||
|
- **5 GB = 2,500 JPY**
|
||||||
|
- **10 GB = 5,000 JPY**
|
||||||
|
- **20 GB = 10,000 JPY**
|
||||||
|
- **50 GB = 25,000 JPY**
|
||||||
|
|
||||||
|
### ⚠️ **Error Handling**:
|
||||||
|
- **Payment Failed**: No data added, user notified
|
||||||
|
- **Freebit Failed**: Payment captured but data not added (requires manual intervention)
|
||||||
|
- **Invoice Creation Failed**: No charge, no data added
|
||||||
|
|
||||||
|
### 📝 **Implementation Files Modified**:
|
||||||
|
1. `apps/bff/src/vendors/whmcs/types/whmcs-api.types.ts` - Added WHMCS API types
|
||||||
|
2. `apps/bff/src/vendors/whmcs/services/whmcs-connection.service.ts` - Added API methods
|
||||||
|
3. `apps/bff/src/vendors/whmcs/services/whmcs-invoice.service.ts` - Added invoice creation
|
||||||
|
4. `apps/bff/src/vendors/whmcs/whmcs.service.ts` - Exposed new methods
|
||||||
|
5. `apps/bff/src/subscriptions/sim-management.service.ts` - Complete payment flow
|
||||||
|
|
||||||
|
## 🎯 **Latest Update: Simplified Top-Up Interface (January 2025)**
|
||||||
|
|
||||||
|
### ✅ **Interface Improvements**:
|
||||||
|
|
||||||
|
#### **Simplified Top-Up Modal**
|
||||||
|
- ✅ **Custom GB Input**: Users can now enter any amount of GB (0.1 - 100 GB)
|
||||||
|
- ✅ **Real-time Cost Calculation**: Shows JPY cost as user types (1GB = 500 JPY)
|
||||||
|
- ✅ **Removed Complexity**: No more preset buttons, campaign codes, or scheduling
|
||||||
|
- ✅ **Cleaner UX**: Single input field with immediate cost feedback
|
||||||
|
|
||||||
|
#### **Updated Backend**
|
||||||
|
- ✅ **Simplified API**: Only requires `quotaMb` parameter
|
||||||
|
- ✅ **Removed Optional Fields**: No more `campaignCode`, `expiryDate`, or `scheduledAt`
|
||||||
|
- ✅ **Streamlined Processing**: Direct payment → data addition flow
|
||||||
|
|
||||||
|
#### **New User Experience**
|
||||||
|
```
|
||||||
|
1. User clicks "Top Up Data"
|
||||||
|
2. Enters desired GB amount (e.g., "2.5")
|
||||||
|
3. Sees real-time cost calculation (¥1,250)
|
||||||
|
4. Clicks "Top Up Now - ¥1,250"
|
||||||
|
5. Payment processed → Data added
|
||||||
|
```
|
||||||
|
|
||||||
|
### 📊 **Interface Changes**:
|
||||||
|
| **Before** | **After** |
|
||||||
|
|------------|-----------|
|
||||||
|
| 6 preset buttons (1GB, 2GB, 5GB, etc.) | Single GB input field (0.1-100 GB) |
|
||||||
|
| Campaign code input | Removed |
|
||||||
|
| Schedule date picker | Removed |
|
||||||
|
| Complex validation | Simple amount validation |
|
||||||
|
| Multiple form fields | Single input + cost display |
|
||||||
|
|
||||||
|
**🏆 The SIM management system is now production-ready with complete payment processing and simplified user interface!**
|
||||||
|
|||||||
@ -7,7 +7,8 @@ export type BillingCycle =
|
|||||||
| "Semi-Annually"
|
| "Semi-Annually"
|
||||||
| "Annually"
|
| "Annually"
|
||||||
| "Biennially"
|
| "Biennially"
|
||||||
| "Triennially";
|
| "Triennially"
|
||||||
|
| "One-time";
|
||||||
|
|
||||||
export interface Subscription {
|
export interface Subscription {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user