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:
barsa 2025-11-18 11:14:05 +09:00
parent 6d327d3ede
commit 1220f219e4
12 changed files with 445 additions and 146 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -41,6 +41,10 @@ export type {
SimOrderActivationRequest,
SimOrderActivationMnp,
SimOrderActivationAddons,
// Pricing types
SimTopUpPricing,
SimTopUpPricingPreviewRequest,
SimTopUpPricingPreviewResponse,
} from './schema';
// Provider adapters

View File

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