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 { SimUsageService } from "./services/sim-usage.service";
|
||||
import { SimTopUpService } from "./services/sim-topup.service";
|
||||
import { SimTopUpPricingService } from "./services/sim-topup-pricing.service";
|
||||
import { SimPlanService } from "./services/sim-plan.service";
|
||||
import { SimCancellationService } from "./services/sim-cancellation.service";
|
||||
import { EsimManagementService } from "./services/esim-management.service";
|
||||
@ -35,6 +36,7 @@ import { SimManagementProcessor } from "./queue/sim-management.processor";
|
||||
SimDetailsService,
|
||||
SimUsageService,
|
||||
SimTopUpService,
|
||||
SimTopUpPricingService,
|
||||
SimPlanService,
|
||||
SimCancellationService,
|
||||
EsimManagementService,
|
||||
@ -51,6 +53,7 @@ import { SimManagementProcessor } from "./queue/sim-management.processor";
|
||||
SimDetailsService,
|
||||
SimUsageService,
|
||||
SimTopUpService,
|
||||
SimTopUpPricingService,
|
||||
SimPlanService,
|
||||
SimCancellationService,
|
||||
EsimManagementService,
|
||||
|
||||
@ -13,6 +13,7 @@ import {
|
||||
} from "@nestjs/common";
|
||||
import { SubscriptionsService } from "./subscriptions.service";
|
||||
import { SimManagementService } from "./sim-management.service";
|
||||
import { SimTopUpPricingService } from "./sim-management/services/sim-topup-pricing.service";
|
||||
|
||||
import {
|
||||
Subscription,
|
||||
@ -53,7 +54,8 @@ type SubscriptionInvoiceQuery = z.infer<typeof subscriptionInvoiceQuerySchema>;
|
||||
export class SubscriptionsController {
|
||||
constructor(
|
||||
private readonly subscriptionsService: SubscriptionsService,
|
||||
private readonly simManagementService: SimManagementService
|
||||
private readonly simManagementService: SimManagementService,
|
||||
private readonly simTopUpPricingService: SimTopUpPricingService
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
@ -99,6 +101,25 @@ export class SubscriptionsController {
|
||||
|
||||
// ==================== 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")
|
||||
async debugSimSubscription(
|
||||
@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";
|
||||
|
||||
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 =
|
||||
| "service"
|
||||
| "installation"
|
||||
| "addon"
|
||||
| "activation"
|
||||
| "other";
|
||||
import type { OrderDisplayItem } from "@customer-portal/domain/orders";
|
||||
|
||||
export type OrderDisplayItemChargeKind = "monthly" | "one-time" | "other";
|
||||
export {
|
||||
buildOrderDisplayItems,
|
||||
categorizeOrderItem,
|
||||
} from "@customer-portal/domain/orders";
|
||||
|
||||
export interface OrderDisplayItemCharge {
|
||||
kind: OrderDisplayItemChargeKind;
|
||||
amount: number;
|
||||
label: string;
|
||||
suffix?: string;
|
||||
}
|
||||
export type {
|
||||
OrderDisplayItem,
|
||||
OrderDisplayItemCategory,
|
||||
OrderDisplayItemCharge,
|
||||
OrderDisplayItemChargeKind,
|
||||
} from "@customer-portal/domain/orders";
|
||||
|
||||
export interface OrderDisplayItem {
|
||||
id: string;
|
||||
name: string;
|
||||
quantity?: number;
|
||||
status?: string;
|
||||
primaryCategory: OrderDisplayItemCategory;
|
||||
categories: OrderDisplayItemCategory[];
|
||||
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 {
|
||||
/**
|
||||
* Summarize order display items for compact display
|
||||
*/
|
||||
export function summarizeOrderDisplayItems(
|
||||
items: OrderDisplayItem[],
|
||||
fallback: string
|
||||
): string {
|
||||
if (items.length === 0) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
@ -1,8 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import React, { useState } from "react";
|
||||
import React, { useState, useEffect } from "react";
|
||||
import { XMarkIcon, PlusIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline";
|
||||
import { apiClient } from "@/lib/api";
|
||||
import { useSimTopUpPricing } from "../hooks/useSimTopUpPricing";
|
||||
import type { SimTopUpPricingPreviewResponse } from "@customer-portal/domain/sim";
|
||||
|
||||
interface TopUpModalProps {
|
||||
subscriptionId: number;
|
||||
@ -14,6 +16,22 @@ interface TopUpModalProps {
|
||||
export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopUpModalProps) {
|
||||
const [gbAmount, setGbAmount] = useState<string>("1");
|
||||
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 gb = parseInt(gbAmount, 10);
|
||||
@ -21,20 +39,21 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
};
|
||||
|
||||
const isValidAmount = () => {
|
||||
const gb = Number(gbAmount);
|
||||
return Number.isInteger(gb) && gb >= 1 && gb <= 50; // 1-50 GB, whole numbers only (Freebit API limit)
|
||||
if (!pricing) return false;
|
||||
const mb = getCurrentAmountMb();
|
||||
return mb >= pricing.minQuotaMb && mb <= pricing.maxQuotaMb;
|
||||
};
|
||||
|
||||
const calculateCost = () => {
|
||||
const gb = parseInt(gbAmount, 10);
|
||||
return isNaN(gb) ? 0 : gb * 500; // 1GB = 500 JPY
|
||||
};
|
||||
const displayCost = preview?.totalPriceJpy ?? 0;
|
||||
const pricePerGb = pricing?.pricePerGbJpy ?? 500;
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
@ -110,7 +129,9 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
</div>
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
|
||||
@ -125,20 +146,21 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-lg font-bold text-blue-900">
|
||||
¥{calculateCost().toLocaleString()}
|
||||
¥{displayCost.toLocaleString()}
|
||||
</div>
|
||||
<div className="text-xs text-blue-700">(1GB = ¥500)</div>
|
||||
<div className="text-xs text-blue-700">(1GB = ¥{pricePerGb})</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Validation Warning */}
|
||||
{!isValidAmount() && gbAmount && (
|
||||
{!isValidAmount() && gbAmount && pricing && (
|
||||
<div className="mb-4 bg-red-50 border border-red-200 rounded-lg p-3">
|
||||
<div className="flex items-center">
|
||||
<ExclamationTriangleIcon className="h-4 w-4 text-red-500 mr-2" />
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
@ -156,10 +178,14 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
|
||||
</button>
|
||||
<button
|
||||
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"
|
||||
>
|
||||
{loading ? "Processing..." : `Top Up Now - ¥${calculateCost().toLocaleString()}`}
|
||||
{loading
|
||||
? "Processing..."
|
||||
: pricingLoading
|
||||
? "Loading..."
|
||||
: `Top Up Now - ¥${displayCost.toLocaleString()}`}
|
||||
</button>
|
||||
</div>
|
||||
</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,
|
||||
type OrderConfigurations,
|
||||
type OrderSelections,
|
||||
type OrderItemSummary,
|
||||
type OrderDisplayItem,
|
||||
type OrderDisplayItemCategory,
|
||||
type OrderDisplayItemCharge,
|
||||
type OrderDisplayItemChargeKind,
|
||||
} from "./schema";
|
||||
import { ORDER_TYPE } from "./contract";
|
||||
import type { CheckoutTotals } from "./contract";
|
||||
@ -297,3 +302,94 @@ export function formatScheduledDate(scheduledAt?: string | null): string | undef
|
||||
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,
|
||||
normalizeBillingCycle,
|
||||
normalizeOrderSelections,
|
||||
buildOrderDisplayItems,
|
||||
categorizeOrderItem,
|
||||
type BuildSimOrderConfigurationsOptions,
|
||||
type OrderStatusDescriptor,
|
||||
type OrderStatusInput,
|
||||
@ -71,6 +73,11 @@ export type {
|
||||
CreateOrderRequest,
|
||||
OrderBusinessValidation,
|
||||
SfOrderIdParam,
|
||||
// Display types
|
||||
OrderDisplayItem,
|
||||
OrderDisplayItemCategory,
|
||||
OrderDisplayItemCharge,
|
||||
OrderDisplayItemChargeKind,
|
||||
} from './schema';
|
||||
|
||||
// Provider adapters
|
||||
|
||||
@ -348,3 +348,47 @@ export type CreateOrderRequest = z.infer<typeof createOrderRequestSchema>;
|
||||
export type OrderBusinessValidation = z.infer<typeof orderBusinessValidationSchema>;
|
||||
export type CheckoutBuildCartRequest = z.infer<typeof checkoutBuildCartRequestSchema>;
|
||||
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,
|
||||
SimOrderActivationMnp,
|
||||
SimOrderActivationAddons,
|
||||
// Pricing types
|
||||
SimTopUpPricing,
|
||||
SimTopUpPricingPreviewRequest,
|
||||
SimTopUpPricingPreviewResponse,
|
||||
} from './schema';
|
||||
|
||||
// Provider adapters
|
||||
|
||||
@ -80,6 +80,40 @@ export const simTopUpRequestSchema = z.object({
|
||||
.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({
|
||||
newPlanCode: z.string().min(1, "New plan code is required"),
|
||||
assignGlobalIp: z.boolean().optional(),
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user