Add SIM top-up pricing endpoints and integrate pricing service into subscriptions module
- Introduced new endpoints in SubscriptionsController for retrieving SIM top-up pricing and previewing pricing based on quota. - Integrated SimTopUpPricingService into the subscriptions module to handle pricing logic. - Updated TopUpModal component to utilize the new pricing service for dynamic cost calculations and validation. - Enhanced error handling and user feedback in the TopUpModal for improved user experience during data top-up operations. - Refactored order display utilities to support new pricing structures and ensure consistent presentation across the application.
This commit is contained in:
parent
6d327d3ede
commit
1220f219e4
@ -0,0 +1,98 @@
|
|||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
||||||
|
import type {
|
||||||
|
SimTopUpPricing,
|
||||||
|
SimTopUpPricingPreviewResponse,
|
||||||
|
} from "@customer-portal/domain/sim";
|
||||||
|
import type { SalesforceResponse } from "@customer-portal/domain/common";
|
||||||
|
import { sanitizeSoqlLiteral } from "@bff/integrations/salesforce/utils/soql.util";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimTopUpPricingService {
|
||||||
|
constructor(
|
||||||
|
private readonly sf: SalesforceConnection,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SIM top-up pricing configuration from Salesforce Product2
|
||||||
|
* Looks for a product with SKU matching TOP_UP_PRICING_SKU or similar
|
||||||
|
*/
|
||||||
|
async getTopUpPricing(): Promise<SimTopUpPricing> {
|
||||||
|
try {
|
||||||
|
// Query Salesforce for top-up pricing configuration
|
||||||
|
// Adjust this query based on your actual Salesforce schema
|
||||||
|
const pricingSku = this.configService.get<string>("SIM_TOPUP_PRICING_SKU") || "SIM_TOPUP_DATA";
|
||||||
|
|
||||||
|
const soql = `
|
||||||
|
SELECT
|
||||||
|
Id,
|
||||||
|
ProductCode,
|
||||||
|
Data_Price_Per_GB_JPY__c,
|
||||||
|
Min_Quota_MB__c,
|
||||||
|
Max_Quota_MB__c
|
||||||
|
FROM Product2
|
||||||
|
WHERE ProductCode = '${sanitizeSoqlLiteral(pricingSku)}'
|
||||||
|
AND IsActive = true
|
||||||
|
LIMIT 1
|
||||||
|
`;
|
||||||
|
|
||||||
|
interface PricingRecord {
|
||||||
|
Data_Price_Per_GB_JPY__c?: number;
|
||||||
|
Min_Quota_MB__c?: number;
|
||||||
|
Max_Quota_MB__c?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = (await this.sf.query(soql, {
|
||||||
|
label: "sim-topup:pricing:get",
|
||||||
|
})) as SalesforceResponse<PricingRecord>;
|
||||||
|
|
||||||
|
if (result.records && result.records.length > 0) {
|
||||||
|
const record = result.records[0];
|
||||||
|
return {
|
||||||
|
pricePerGbJpy: record?.Data_Price_Per_GB_JPY__c || 500, // fallback to 500
|
||||||
|
minQuotaMb: record?.Min_Quota_MB__c || 1000, // 1GB min
|
||||||
|
maxQuotaMb: record?.Max_Quota_MB__c || 51200, // 50GB max
|
||||||
|
currency: "JPY",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to default pricing if not found in Salesforce
|
||||||
|
this.logger.warn("SIM top-up pricing not found in Salesforce, using defaults");
|
||||||
|
return this.getDefaultPricing();
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to fetch SIM top-up pricing from Salesforce", { error });
|
||||||
|
return this.getDefaultPricing();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate pricing preview for a given quota
|
||||||
|
*/
|
||||||
|
async calculatePricingPreview(quotaMb: number): Promise<SimTopUpPricingPreviewResponse> {
|
||||||
|
const pricing = await this.getTopUpPricing();
|
||||||
|
const quotaGb = quotaMb / 1000;
|
||||||
|
const totalPriceJpy = Math.ceil(quotaGb) * pricing.pricePerGbJpy;
|
||||||
|
|
||||||
|
return {
|
||||||
|
quotaMb,
|
||||||
|
quotaGb,
|
||||||
|
totalPriceJpy,
|
||||||
|
pricePerGbJpy: pricing.pricePerGbJpy,
|
||||||
|
currency: pricing.currency,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private getDefaultPricing(): SimTopUpPricing {
|
||||||
|
return {
|
||||||
|
pricePerGbJpy: 500,
|
||||||
|
minQuotaMb: 1000, // 1GB
|
||||||
|
maxQuotaMb: 51200, // 50GB
|
||||||
|
currency: "JPY",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ -11,6 +11,7 @@ import { SimOrchestratorService } from "./services/sim-orchestrator.service";
|
|||||||
import { SimDetailsService } from "./services/sim-details.service";
|
import { SimDetailsService } from "./services/sim-details.service";
|
||||||
import { SimUsageService } from "./services/sim-usage.service";
|
import { SimUsageService } from "./services/sim-usage.service";
|
||||||
import { SimTopUpService } from "./services/sim-topup.service";
|
import { SimTopUpService } from "./services/sim-topup.service";
|
||||||
|
import { SimTopUpPricingService } from "./services/sim-topup-pricing.service";
|
||||||
import { SimPlanService } from "./services/sim-plan.service";
|
import { SimPlanService } from "./services/sim-plan.service";
|
||||||
import { SimCancellationService } from "./services/sim-cancellation.service";
|
import { SimCancellationService } from "./services/sim-cancellation.service";
|
||||||
import { EsimManagementService } from "./services/esim-management.service";
|
import { EsimManagementService } from "./services/esim-management.service";
|
||||||
@ -35,6 +36,7 @@ import { SimManagementProcessor } from "./queue/sim-management.processor";
|
|||||||
SimDetailsService,
|
SimDetailsService,
|
||||||
SimUsageService,
|
SimUsageService,
|
||||||
SimTopUpService,
|
SimTopUpService,
|
||||||
|
SimTopUpPricingService,
|
||||||
SimPlanService,
|
SimPlanService,
|
||||||
SimCancellationService,
|
SimCancellationService,
|
||||||
EsimManagementService,
|
EsimManagementService,
|
||||||
@ -51,6 +53,7 @@ import { SimManagementProcessor } from "./queue/sim-management.processor";
|
|||||||
SimDetailsService,
|
SimDetailsService,
|
||||||
SimUsageService,
|
SimUsageService,
|
||||||
SimTopUpService,
|
SimTopUpService,
|
||||||
|
SimTopUpPricingService,
|
||||||
SimPlanService,
|
SimPlanService,
|
||||||
SimCancellationService,
|
SimCancellationService,
|
||||||
EsimManagementService,
|
EsimManagementService,
|
||||||
|
|||||||
@ -13,6 +13,7 @@ import {
|
|||||||
} from "@nestjs/common";
|
} from "@nestjs/common";
|
||||||
import { SubscriptionsService } from "./subscriptions.service";
|
import { SubscriptionsService } from "./subscriptions.service";
|
||||||
import { SimManagementService } from "./sim-management.service";
|
import { SimManagementService } from "./sim-management.service";
|
||||||
|
import { SimTopUpPricingService } from "./sim-management/services/sim-topup-pricing.service";
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Subscription,
|
Subscription,
|
||||||
@ -53,7 +54,8 @@ type SubscriptionInvoiceQuery = z.infer<typeof subscriptionInvoiceQuerySchema>;
|
|||||||
export class SubscriptionsController {
|
export class SubscriptionsController {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly subscriptionsService: SubscriptionsService,
|
private readonly subscriptionsService: SubscriptionsService,
|
||||||
private readonly simManagementService: SimManagementService
|
private readonly simManagementService: SimManagementService,
|
||||||
|
private readonly simTopUpPricingService: SimTopUpPricingService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@Get()
|
@Get()
|
||||||
@ -99,6 +101,25 @@ export class SubscriptionsController {
|
|||||||
|
|
||||||
// ==================== SIM Management Endpoints ====================
|
// ==================== SIM Management Endpoints ====================
|
||||||
|
|
||||||
|
@Get("sim/top-up/pricing")
|
||||||
|
@Header("Cache-Control", "public, max-age=3600") // 1 hour, pricing is relatively static
|
||||||
|
async getSimTopUpPricing() {
|
||||||
|
const pricing = await this.simTopUpPricingService.getTopUpPricing();
|
||||||
|
return { success: true, data: pricing };
|
||||||
|
}
|
||||||
|
|
||||||
|
@Get("sim/top-up/pricing/preview")
|
||||||
|
@Header("Cache-Control", "public, max-age=3600") // 1 hour, pricing calculation is deterministic
|
||||||
|
async previewSimTopUpPricing(@Query("quotaMb") quotaMb: string) {
|
||||||
|
const quotaMbNum = parseInt(quotaMb, 10);
|
||||||
|
if (isNaN(quotaMbNum) || quotaMbNum <= 0) {
|
||||||
|
throw new BadRequestException("Invalid quotaMb parameter");
|
||||||
|
}
|
||||||
|
|
||||||
|
const preview = await this.simTopUpPricingService.calculatePricingPreview(quotaMbNum);
|
||||||
|
return { success: true, data: preview };
|
||||||
|
}
|
||||||
|
|
||||||
@Get(":id/sim/debug")
|
@Get(":id/sim/debug")
|
||||||
async debugSimSubscription(
|
async debugSimSubscription(
|
||||||
@Request() req: RequestWithUser,
|
@Request() req: RequestWithUser,
|
||||||
|
|||||||
@ -1,3 +1,8 @@
|
|||||||
|
/**
|
||||||
|
* Presentation-layer pricing utilities
|
||||||
|
* These are UI-specific formatting helpers, not business logic
|
||||||
|
*/
|
||||||
|
|
||||||
import type { CatalogProductBase } from "@customer-portal/domain/catalog";
|
import type { CatalogProductBase } from "@customer-portal/domain/catalog";
|
||||||
|
|
||||||
export interface PriceInfo {
|
export interface PriceInfo {
|
||||||
|
|||||||
@ -1,137 +1,29 @@
|
|||||||
import type { OrderItemSummary } from "@customer-portal/domain/orders";
|
/**
|
||||||
import { normalizeBillingCycle } from "@/features/orders/utils/order-presenters";
|
* Order display utilities
|
||||||
|
* Re-exports domain logic for backward compatibility in the frontend
|
||||||
|
*/
|
||||||
|
|
||||||
export type OrderDisplayItemCategory =
|
import type { OrderDisplayItem } from "@customer-portal/domain/orders";
|
||||||
| "service"
|
|
||||||
| "installation"
|
|
||||||
| "addon"
|
|
||||||
| "activation"
|
|
||||||
| "other";
|
|
||||||
|
|
||||||
export type OrderDisplayItemChargeKind = "monthly" | "one-time" | "other";
|
export {
|
||||||
|
buildOrderDisplayItems,
|
||||||
|
categorizeOrderItem,
|
||||||
|
} from "@customer-portal/domain/orders";
|
||||||
|
|
||||||
export interface OrderDisplayItemCharge {
|
export type {
|
||||||
kind: OrderDisplayItemChargeKind;
|
OrderDisplayItem,
|
||||||
amount: number;
|
OrderDisplayItemCategory,
|
||||||
label: string;
|
OrderDisplayItemCharge,
|
||||||
suffix?: string;
|
OrderDisplayItemChargeKind,
|
||||||
}
|
} from "@customer-portal/domain/orders";
|
||||||
|
|
||||||
export interface OrderDisplayItem {
|
/**
|
||||||
id: string;
|
* Summarize order display items for compact display
|
||||||
name: string;
|
*/
|
||||||
quantity?: number;
|
export function summarizeOrderDisplayItems(
|
||||||
status?: string;
|
items: OrderDisplayItem[],
|
||||||
primaryCategory: OrderDisplayItemCategory;
|
fallback: string
|
||||||
categories: OrderDisplayItemCategory[];
|
): string {
|
||||||
charges: OrderDisplayItemCharge[];
|
|
||||||
included: boolean;
|
|
||||||
sourceItems: OrderItemSummary[];
|
|
||||||
isBundle: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
const CHARGE_ORDER: Record<OrderDisplayItemChargeKind, number> = {
|
|
||||||
monthly: 0,
|
|
||||||
"one-time": 1,
|
|
||||||
other: 2,
|
|
||||||
};
|
|
||||||
|
|
||||||
const MONTHLY_SUFFIX = "/ month";
|
|
||||||
|
|
||||||
const normalizeItemClass = (itemClass?: string | null): string => (itemClass ?? "").toLowerCase();
|
|
||||||
|
|
||||||
const resolveCategory = (item: OrderItemSummary): OrderDisplayItemCategory => {
|
|
||||||
const normalizedClass = normalizeItemClass(item.itemClass);
|
|
||||||
if (normalizedClass.includes("service")) return "service";
|
|
||||||
if (normalizedClass.includes("installation")) return "installation";
|
|
||||||
if (normalizedClass.includes("activation")) return "activation";
|
|
||||||
if (normalizedClass.includes("add-on") || normalizedClass.includes("addon")) return "addon";
|
|
||||||
return "other";
|
|
||||||
};
|
|
||||||
|
|
||||||
const coerceNumber = (value: unknown): number => {
|
|
||||||
if (typeof value === "number" && Number.isFinite(value)) {
|
|
||||||
return value;
|
|
||||||
}
|
|
||||||
return 0;
|
|
||||||
};
|
|
||||||
|
|
||||||
const aggregateCharges = (items: OrderItemSummary[]): OrderDisplayItemCharge[] => {
|
|
||||||
const accumulator = new Map<
|
|
||||||
string,
|
|
||||||
OrderDisplayItemCharge & { kind: OrderDisplayItemChargeKind }
|
|
||||||
>();
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const amount = coerceNumber(item.totalPrice ?? item.unitPrice);
|
|
||||||
const normalizedCycle = normalizeBillingCycle(item.billingCycle ?? undefined);
|
|
||||||
|
|
||||||
let kind: OrderDisplayItemChargeKind = "other";
|
|
||||||
let key = "other";
|
|
||||||
let label = item.billingCycle?.trim() || "Billing";
|
|
||||||
let suffix: string | undefined;
|
|
||||||
|
|
||||||
if (normalizedCycle === "monthly") {
|
|
||||||
kind = "monthly";
|
|
||||||
key = "monthly";
|
|
||||||
label = "Monthly";
|
|
||||||
suffix = MONTHLY_SUFFIX;
|
|
||||||
} else if (normalizedCycle === "onetime" || normalizedCycle === "free") {
|
|
||||||
kind = "one-time";
|
|
||||||
key = "one-time";
|
|
||||||
label = "One-time";
|
|
||||||
} else if (typeof item.billingCycle === "string" && item.billingCycle.length > 0) {
|
|
||||||
key = `other:${item.billingCycle.toLowerCase()}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
const existing = accumulator.get(key);
|
|
||||||
if (existing) {
|
|
||||||
existing.amount += amount;
|
|
||||||
} else {
|
|
||||||
accumulator.set(key, { kind, amount, label, suffix });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return Array.from(accumulator.values())
|
|
||||||
.map(({ kind, amount, label, suffix }) => ({ kind, amount, label, suffix }))
|
|
||||||
.sort((a, b) => {
|
|
||||||
const orderDiff = CHARGE_ORDER[a.kind] - CHARGE_ORDER[b.kind];
|
|
||||||
if (orderDiff !== 0) return orderDiff;
|
|
||||||
return a.label.localeCompare(b.label);
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
const isIncludedGroup = (charges: OrderDisplayItemCharge[]): boolean =>
|
|
||||||
charges.every(charge => charge.amount <= 0);
|
|
||||||
|
|
||||||
export function buildOrderDisplayItems(
|
|
||||||
items: OrderItemSummary[] | null | undefined
|
|
||||||
): OrderDisplayItem[] {
|
|
||||||
if (!Array.isArray(items) || items.length === 0) {
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
// Map items to display format - keep the order from the backend
|
|
||||||
return items.map((item, index) => {
|
|
||||||
const charges = aggregateCharges([item]);
|
|
||||||
const isBundled = Boolean(item.isBundledAddon);
|
|
||||||
|
|
||||||
return {
|
|
||||||
id: item.productId || item.sku || `order-item-${index}`,
|
|
||||||
name: item.productName || item.name || "Service item",
|
|
||||||
quantity: item.quantity ?? undefined,
|
|
||||||
status: item.status ?? undefined,
|
|
||||||
primaryCategory: resolveCategory(item),
|
|
||||||
categories: [resolveCategory(item)],
|
|
||||||
charges,
|
|
||||||
included: isIncludedGroup(charges),
|
|
||||||
sourceItems: [item],
|
|
||||||
isBundle: isBundled,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function summarizeOrderDisplayItems(items: OrderDisplayItem[], fallback: string): string {
|
|
||||||
if (items.length === 0) {
|
if (items.length === 0) {
|
||||||
return fallback;
|
return fallback;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,8 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import React, { useState } from "react";
|
import React, { useState, useEffect } from "react";
|
||||||
import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||||
import { apiClient } from "@/lib/api";
|
import { apiClient } from "@/lib/api";
|
||||||
|
import { useSimTopUpPricing } from "../hooks/useSimTopUpPricing";
|
||||||
|
import type { SimTopUpPricingPreviewResponse } from "@customer-portal/domain/sim";
|
||||||
|
|
||||||
interface TopUpModalProps {
|
interface TopUpModalProps {
|
||||||
subscriptionId: number;
|
subscriptionId: number;
|
||||||
@ -14,6 +16,22 @@ interface TopUpModalProps {
|
|||||||
export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) {
|
export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) {
|
||||||
const [gbAmount, setGbAmount] = useState<string>("1");
|
const [gbAmount, setGbAmount] = useState<string>("1");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const { pricing, loading: pricingLoading, calculatePreview } = useSimTopUpPricing();
|
||||||
|
const [preview, setPreview] = useState<SimTopUpPricingPreviewResponse | null>(null);
|
||||||
|
|
||||||
|
// Update preview when gbAmount changes
|
||||||
|
useEffect(() => {
|
||||||
|
const updatePreview = async () => {
|
||||||
|
const mb = parseInt(gbAmount, 10) * 1000;
|
||||||
|
if (!isNaN(mb) && mb > 0) {
|
||||||
|
const result = await calculatePreview(mb);
|
||||||
|
setPreview(result);
|
||||||
|
} else {
|
||||||
|
setPreview(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
void updatePreview();
|
||||||
|
}, [gbAmount, calculatePreview]);
|
||||||
|
|
||||||
const getCurrentAmountMb = () => {
|
const getCurrentAmountMb = () => {
|
||||||
const gb = parseInt(gbAmount, 10);
|
const gb = parseInt(gbAmount, 10);
|
||||||
@ -21,20 +39,21 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
};
|
};
|
||||||
|
|
||||||
const isValidAmount = () => {
|
const isValidAmount = () => {
|
||||||
const gb = Number(gbAmount);
|
if (!pricing) return false;
|
||||||
return Number.isInteger(gb) && gb >= 1 && gb <= 50; // 1-50 GB, whole numbers only (Freebit API limit)
|
const mb = getCurrentAmountMb();
|
||||||
|
return mb >= pricing.minQuotaMb && mb <= pricing.maxQuotaMb;
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateCost = () => {
|
const displayCost = preview?.totalPriceJpy ?? 0;
|
||||||
const gb = parseInt(gbAmount, 10);
|
const pricePerGb = pricing?.pricePerGbJpy ?? 500;
|
||||||
return isNaN(gb) ? 0 : 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 whole number between 1 GB and 100 GB");
|
onError(
|
||||||
|
`Please enter a valid amount between ${pricing ? pricing.minQuotaMb / 1000 : 1} GB and ${pricing ? pricing.maxQuotaMb / 1000 : 50} GB`
|
||||||
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -110,7 +129,9 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-gray-500 mt-1">
|
<p className="text-xs text-gray-500 mt-1">
|
||||||
Enter the amount of data you want to add (1 - 50 GB, whole numbers)
|
Enter the amount of data you want to add (
|
||||||
|
{pricing ? `${pricing.minQuotaMb / 1000} - ${pricing.maxQuotaMb / 1000}` : "1 - 50"}{" "}
|
||||||
|
GB, whole numbers)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -125,20 +146,21 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
</div>
|
</div>
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-lg font-bold text-blue-900">
|
<div className="text-lg font-bold text-blue-900">
|
||||||
¥{calculateCost().toLocaleString()}
|
¥{displayCost.toLocaleString()}
|
||||||
</div>
|
</div>
|
||||||
<div className="text-xs text-blue-700">(1GB = ¥500)</div>
|
<div className="text-xs text-blue-700">(1GB = ¥{pricePerGb})</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Validation Warning */}
|
{/* Validation Warning */}
|
||||||
{!isValidAmount() && gbAmount && (
|
{!isValidAmount() && gbAmount && pricing && (
|
||||||
<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 a whole number between 1 GB and 50 GB
|
Amount must be between {pricing.minQuotaMb / 1000} GB and{" "}
|
||||||
|
{pricing.maxQuotaMb / 1000} GB
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -156,10 +178,14 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
|||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={loading || !isValidAmount()}
|
disabled={loading || !isValidAmount() || pricingLoading}
|
||||||
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..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
|
{loading
|
||||||
|
? "Processing..."
|
||||||
|
: pricingLoading
|
||||||
|
? "Loading..."
|
||||||
|
: `Top Up Now - ¥${displayCost.toLocaleString()}`}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|||||||
@ -0,0 +1,69 @@
|
|||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { apiClient } from "@/lib/api";
|
||||||
|
import type {
|
||||||
|
SimTopUpPricing,
|
||||||
|
SimTopUpPricingPreviewResponse,
|
||||||
|
} from "@customer-portal/domain/sim";
|
||||||
|
|
||||||
|
interface UseSimTopUpPricingResult {
|
||||||
|
pricing: SimTopUpPricing | null;
|
||||||
|
loading: boolean;
|
||||||
|
error: string | null;
|
||||||
|
calculatePreview: (quotaMb: number) => Promise<SimTopUpPricingPreviewResponse | null>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useSimTopUpPricing(): UseSimTopUpPricingResult {
|
||||||
|
const [pricing, setPricing] = useState<SimTopUpPricing | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let mounted = true;
|
||||||
|
|
||||||
|
const fetchPricing = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const response = await apiClient.GET("/api/subscriptions/sim/top-up/pricing");
|
||||||
|
|
||||||
|
if (mounted && response.data) {
|
||||||
|
const data = response.data as { success: boolean; data: SimTopUpPricing };
|
||||||
|
setPricing(data.data);
|
||||||
|
setError(null);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (mounted) {
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to load pricing");
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
if (mounted) {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
void fetchPricing();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
mounted = false;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const calculatePreview = async (
|
||||||
|
quotaMb: number
|
||||||
|
): Promise<SimTopUpPricingPreviewResponse | null> => {
|
||||||
|
try {
|
||||||
|
const response = await apiClient.GET("/api/subscriptions/sim/top-up/pricing/preview", {
|
||||||
|
params: { query: { quotaMb: quotaMb.toString() } },
|
||||||
|
});
|
||||||
|
|
||||||
|
const data = response.data as { success: boolean; data: SimTopUpPricingPreviewResponse };
|
||||||
|
return data.data;
|
||||||
|
} catch (err) {
|
||||||
|
console.error("Failed to calculate pricing preview:", err);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return { pricing, loading, error, calculatePreview };
|
||||||
|
}
|
||||||
|
|
||||||
@ -3,6 +3,11 @@ import {
|
|||||||
orderSelectionsSchema,
|
orderSelectionsSchema,
|
||||||
type OrderConfigurations,
|
type OrderConfigurations,
|
||||||
type OrderSelections,
|
type OrderSelections,
|
||||||
|
type OrderItemSummary,
|
||||||
|
type OrderDisplayItem,
|
||||||
|
type OrderDisplayItemCategory,
|
||||||
|
type OrderDisplayItemCharge,
|
||||||
|
type OrderDisplayItemChargeKind,
|
||||||
} from "./schema";
|
} from "./schema";
|
||||||
import { ORDER_TYPE } from "./contract";
|
import { ORDER_TYPE } from "./contract";
|
||||||
import type { CheckoutTotals } from "./contract";
|
import type { CheckoutTotals } from "./contract";
|
||||||
@ -297,3 +302,94 @@ export function formatScheduledDate(scheduledAt?: string | null): string | undef
|
|||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Categorize order item by itemClass
|
||||||
|
*/
|
||||||
|
export function categorizeOrderItem(itemClass?: string | null): OrderDisplayItemCategory {
|
||||||
|
const normalized = (itemClass ?? "").toLowerCase();
|
||||||
|
if (normalized.includes("service")) return "service";
|
||||||
|
if (normalized.includes("installation")) return "installation";
|
||||||
|
if (normalized.includes("activation")) return "activation";
|
||||||
|
if (normalized.includes("add-on") || normalized.includes("addon")) return "addon";
|
||||||
|
return "other";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build display items from order item summaries
|
||||||
|
*/
|
||||||
|
export function buildOrderDisplayItems(
|
||||||
|
items: OrderItemSummary[] | null | undefined
|
||||||
|
): OrderDisplayItem[] {
|
||||||
|
if (!Array.isArray(items) || items.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.map((item, index) => {
|
||||||
|
const category = categorizeOrderItem(item.itemClass);
|
||||||
|
const charges = aggregateCharges([item]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: item.productId || item.sku || `order-item-${index}`,
|
||||||
|
name: item.productName || item.name || "Service item",
|
||||||
|
quantity: item.quantity ?? undefined,
|
||||||
|
status: item.status ?? undefined,
|
||||||
|
primaryCategory: category,
|
||||||
|
categories: [category],
|
||||||
|
charges,
|
||||||
|
included: charges.every((charge) => charge.amount <= 0),
|
||||||
|
sourceItems: [item],
|
||||||
|
isBundle: Boolean(item.isBundledAddon),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function aggregateCharges(items: OrderItemSummary[]): OrderDisplayItemCharge[] {
|
||||||
|
const accumulator = new Map<
|
||||||
|
string,
|
||||||
|
OrderDisplayItemCharge & { kind: OrderDisplayItemChargeKind }
|
||||||
|
>();
|
||||||
|
const CHARGE_ORDER: Record<OrderDisplayItemChargeKind, number> = {
|
||||||
|
monthly: 0,
|
||||||
|
"one-time": 1,
|
||||||
|
other: 2,
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const item of items) {
|
||||||
|
const amount = Number(item.totalPrice ?? item.unitPrice ?? 0);
|
||||||
|
const normalizedCycle = normalizeBillingCycle(item.billingCycle ?? undefined);
|
||||||
|
|
||||||
|
let kind: OrderDisplayItemChargeKind = "other";
|
||||||
|
let key = "other";
|
||||||
|
let label = item.billingCycle?.trim() || "Billing";
|
||||||
|
let suffix: string | undefined;
|
||||||
|
|
||||||
|
if (normalizedCycle === "monthly") {
|
||||||
|
kind = "monthly";
|
||||||
|
key = "monthly";
|
||||||
|
label = "Monthly";
|
||||||
|
suffix = "/ month";
|
||||||
|
} else if (normalizedCycle === "onetime" || normalizedCycle === "free") {
|
||||||
|
kind = "one-time";
|
||||||
|
key = "one-time";
|
||||||
|
label = "One-time";
|
||||||
|
} else if (typeof item.billingCycle === "string" && item.billingCycle.length > 0) {
|
||||||
|
key = `other:${item.billingCycle.toLowerCase()}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const existing = accumulator.get(key);
|
||||||
|
if (existing) {
|
||||||
|
existing.amount += amount;
|
||||||
|
} else {
|
||||||
|
accumulator.set(key, { kind, amount, label, suffix });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return Array.from(accumulator.values())
|
||||||
|
.map(({ kind, amount, label, suffix }) => ({ kind, amount, label, suffix }))
|
||||||
|
.sort((a, b) => {
|
||||||
|
const orderDiff = CHARGE_ORDER[a.kind] - CHARGE_ORDER[b.kind];
|
||||||
|
if (orderDiff !== 0) return orderDiff;
|
||||||
|
return a.label.localeCompare(b.label);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|||||||
@ -44,6 +44,8 @@ export {
|
|||||||
buildSimOrderConfigurations,
|
buildSimOrderConfigurations,
|
||||||
normalizeBillingCycle,
|
normalizeBillingCycle,
|
||||||
normalizeOrderSelections,
|
normalizeOrderSelections,
|
||||||
|
buildOrderDisplayItems,
|
||||||
|
categorizeOrderItem,
|
||||||
type BuildSimOrderConfigurationsOptions,
|
type BuildSimOrderConfigurationsOptions,
|
||||||
type OrderStatusDescriptor,
|
type OrderStatusDescriptor,
|
||||||
type OrderStatusInput,
|
type OrderStatusInput,
|
||||||
@ -71,6 +73,11 @@ export type {
|
|||||||
CreateOrderRequest,
|
CreateOrderRequest,
|
||||||
OrderBusinessValidation,
|
OrderBusinessValidation,
|
||||||
SfOrderIdParam,
|
SfOrderIdParam,
|
||||||
|
// Display types
|
||||||
|
OrderDisplayItem,
|
||||||
|
OrderDisplayItemCategory,
|
||||||
|
OrderDisplayItemCharge,
|
||||||
|
OrderDisplayItemChargeKind,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
// Provider adapters
|
// Provider adapters
|
||||||
|
|||||||
@ -348,3 +348,47 @@ export type CreateOrderRequest = z.infer<typeof createOrderRequestSchema>;
|
|||||||
export type OrderBusinessValidation = z.infer<typeof orderBusinessValidationSchema>;
|
export type OrderBusinessValidation = z.infer<typeof orderBusinessValidationSchema>;
|
||||||
export type CheckoutBuildCartRequest = z.infer<typeof checkoutBuildCartRequestSchema>;
|
export type CheckoutBuildCartRequest = z.infer<typeof checkoutBuildCartRequestSchema>;
|
||||||
export type CheckoutBuildCartResponse = z.infer<typeof checkoutBuildCartResponseSchema>;
|
export type CheckoutBuildCartResponse = z.infer<typeof checkoutBuildCartResponseSchema>;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Order Display Types (for UI presentation)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
export const orderDisplayItemCategorySchema = z.enum([
|
||||||
|
"service",
|
||||||
|
"installation",
|
||||||
|
"addon",
|
||||||
|
"activation",
|
||||||
|
"other",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const orderDisplayItemChargeKindSchema = z.enum([
|
||||||
|
"monthly",
|
||||||
|
"one-time",
|
||||||
|
"other",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const orderDisplayItemChargeSchema = z.object({
|
||||||
|
kind: orderDisplayItemChargeKindSchema,
|
||||||
|
amount: z.number(),
|
||||||
|
label: z.string(),
|
||||||
|
suffix: z.string().optional(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export const orderDisplayItemSchema = z.object({
|
||||||
|
id: z.string(),
|
||||||
|
name: z.string(),
|
||||||
|
quantity: z.number().optional(),
|
||||||
|
status: z.string().optional(),
|
||||||
|
primaryCategory: orderDisplayItemCategorySchema,
|
||||||
|
categories: z.array(orderDisplayItemCategorySchema),
|
||||||
|
charges: z.array(orderDisplayItemChargeSchema),
|
||||||
|
included: z.boolean(),
|
||||||
|
sourceItems: z.array(orderItemSummarySchema),
|
||||||
|
isBundle: z.boolean(),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Types
|
||||||
|
export type OrderDisplayItemCategory = z.infer<typeof orderDisplayItemCategorySchema>;
|
||||||
|
export type OrderDisplayItemChargeKind = z.infer<typeof orderDisplayItemChargeKindSchema>;
|
||||||
|
export type OrderDisplayItemCharge = z.infer<typeof orderDisplayItemChargeSchema>;
|
||||||
|
export type OrderDisplayItem = z.infer<typeof orderDisplayItemSchema>;
|
||||||
|
|||||||
@ -41,6 +41,10 @@ export type {
|
|||||||
SimOrderActivationRequest,
|
SimOrderActivationRequest,
|
||||||
SimOrderActivationMnp,
|
SimOrderActivationMnp,
|
||||||
SimOrderActivationAddons,
|
SimOrderActivationAddons,
|
||||||
|
// Pricing types
|
||||||
|
SimTopUpPricing,
|
||||||
|
SimTopUpPricingPreviewRequest,
|
||||||
|
SimTopUpPricingPreviewResponse,
|
||||||
} from './schema';
|
} from './schema';
|
||||||
|
|
||||||
// Provider adapters
|
// Provider adapters
|
||||||
|
|||||||
@ -80,6 +80,40 @@ export const simTopUpRequestSchema = z.object({
|
|||||||
.max(51200, "Quota must be 50GB or less"),
|
.max(51200, "Quota must be 50GB or less"),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for SIM top-up pricing information
|
||||||
|
*/
|
||||||
|
export const simTopUpPricingSchema = z.object({
|
||||||
|
pricePerGbJpy: z.number().positive("Price per GB must be positive"),
|
||||||
|
minQuotaMb: z.number().int().positive("Minimum quota must be positive"),
|
||||||
|
maxQuotaMb: z.number().int().positive("Maximum quota must be positive"),
|
||||||
|
currency: z.string().default("JPY"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SimTopUpPricing = z.infer<typeof simTopUpPricingSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for top-up pricing preview request
|
||||||
|
*/
|
||||||
|
export const simTopUpPricingPreviewRequestSchema = z.object({
|
||||||
|
quotaMb: z.number().int().positive("Quota must be positive"),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SimTopUpPricingPreviewRequest = z.infer<typeof simTopUpPricingPreviewRequestSchema>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Schema for top-up pricing preview response
|
||||||
|
*/
|
||||||
|
export const simTopUpPricingPreviewResponseSchema = z.object({
|
||||||
|
quotaMb: z.number(),
|
||||||
|
quotaGb: z.number(),
|
||||||
|
totalPriceJpy: z.number(),
|
||||||
|
pricePerGbJpy: z.number(),
|
||||||
|
currency: z.string(),
|
||||||
|
});
|
||||||
|
|
||||||
|
export type SimTopUpPricingPreviewResponse = z.infer<typeof simTopUpPricingPreviewResponseSchema>;
|
||||||
|
|
||||||
export const simPlanChangeRequestSchema = z.object({
|
export const simPlanChangeRequestSchema = z.object({
|
||||||
newPlanCode: z.string().min(1, "New plan code is required"),
|
newPlanCode: z.string().min(1, "New plan code is required"),
|
||||||
assignGlobalIp: z.boolean().optional(),
|
assignGlobalIp: z.boolean().optional(),
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user