From 1220f219e4a5f3f48bf43982d94db732e2c7cabd Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 18 Nov 2025 11:14:05 +0900 Subject: [PATCH] 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. --- .../services/sim-topup-pricing.service.ts | 98 +++++++++++ .../sim-management/sim-management.module.ts | 3 + .../subscriptions/subscriptions.controller.ts | 23 ++- .../src/features/catalog/utils/pricing.ts | 5 + .../features/orders/utils/order-display.ts | 152 +++--------------- .../sim-management/components/TopUpModal.tsx | 56 +++++-- .../hooks/useSimTopUpPricing.ts | 69 ++++++++ packages/domain/orders/helpers.ts | 96 +++++++++++ packages/domain/orders/index.ts | 7 + packages/domain/orders/schema.ts | 44 +++++ packages/domain/sim/index.ts | 4 + packages/domain/sim/schema.ts | 34 ++++ 12 files changed, 445 insertions(+), 146 deletions(-) create mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-topup-pricing.service.ts create mode 100644 apps/portal/src/features/sim-management/hooks/useSimTopUpPricing.ts diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup-pricing.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup-pricing.service.ts new file mode 100644 index 00000000..859b0e10 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup-pricing.service.ts @@ -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 { + try { + // Query Salesforce for top-up pricing configuration + // Adjust this query based on your actual Salesforce schema + const pricingSku = this.configService.get("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; + + 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 { + 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", + }; + } +} + diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts index b402adc6..e44cdd20 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts @@ -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, diff --git a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts index 2d7940ec..90180a20 100644 --- a/apps/bff/src/modules/subscriptions/subscriptions.controller.ts +++ b/apps/bff/src/modules/subscriptions/subscriptions.controller.ts @@ -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; 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, diff --git a/apps/portal/src/features/catalog/utils/pricing.ts b/apps/portal/src/features/catalog/utils/pricing.ts index e348f6e7..aea8d379 100644 --- a/apps/portal/src/features/catalog/utils/pricing.ts +++ b/apps/portal/src/features/catalog/utils/pricing.ts @@ -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 { diff --git a/apps/portal/src/features/orders/utils/order-display.ts b/apps/portal/src/features/orders/utils/order-display.ts index 7132fe12..4e2db651 100644 --- a/apps/portal/src/features/orders/utils/order-display.ts +++ b/apps/portal/src/features/orders/utils/order-display.ts @@ -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 = { - 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; } diff --git a/apps/portal/src/features/sim-management/components/TopUpModal.tsx b/apps/portal/src/features/sim-management/components/TopUpModal.tsx index 1e3358a0..f6b01b0d 100644 --- a/apps/portal/src/features/sim-management/components/TopUpModal.tsx +++ b/apps/portal/src/features/sim-management/components/TopUpModal.tsx @@ -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("1"); const [loading, setLoading] = useState(false); + const { pricing, loading: pricingLoading, calculatePreview } = useSimTopUpPricing(); + const [preview, setPreview] = useState(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

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

@@ -125,20 +146,21 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
- ¥{calculateCost().toLocaleString()} + ¥{displayCost.toLocaleString()}
-
(1GB = ¥500)
+
(1GB = ¥{pricePerGb})
{/* Validation Warning */} - {!isValidAmount() && gbAmount && ( + {!isValidAmount() && gbAmount && pricing && (

- 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

@@ -156,10 +178,14 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU diff --git a/apps/portal/src/features/sim-management/hooks/useSimTopUpPricing.ts b/apps/portal/src/features/sim-management/hooks/useSimTopUpPricing.ts new file mode 100644 index 00000000..e96f4960 --- /dev/null +++ b/apps/portal/src/features/sim-management/hooks/useSimTopUpPricing.ts @@ -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; +} + +export function useSimTopUpPricing(): UseSimTopUpPricingResult { + const [pricing, setPricing] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(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 => { + 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 }; +} + diff --git a/packages/domain/orders/helpers.ts b/packages/domain/orders/helpers.ts index 1ccceb9e..f2ead6d6 100644 --- a/packages/domain/orders/helpers.ts +++ b/packages/domain/orders/helpers.ts @@ -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 = { + 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); + }); +} diff --git a/packages/domain/orders/index.ts b/packages/domain/orders/index.ts index baf1b8af..78389837 100644 --- a/packages/domain/orders/index.ts +++ b/packages/domain/orders/index.ts @@ -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 diff --git a/packages/domain/orders/schema.ts b/packages/domain/orders/schema.ts index 6203bbd4..bb5dfdab 100644 --- a/packages/domain/orders/schema.ts +++ b/packages/domain/orders/schema.ts @@ -348,3 +348,47 @@ export type CreateOrderRequest = z.infer; export type OrderBusinessValidation = z.infer; export type CheckoutBuildCartRequest = z.infer; export type CheckoutBuildCartResponse = z.infer; + +// ============================================================================ +// 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; +export type OrderDisplayItemChargeKind = z.infer; +export type OrderDisplayItemCharge = z.infer; +export type OrderDisplayItem = z.infer; diff --git a/packages/domain/sim/index.ts b/packages/domain/sim/index.ts index 11159fd1..de2a1f99 100644 --- a/packages/domain/sim/index.ts +++ b/packages/domain/sim/index.ts @@ -41,6 +41,10 @@ export type { SimOrderActivationRequest, SimOrderActivationMnp, SimOrderActivationAddons, + // Pricing types + SimTopUpPricing, + SimTopUpPricingPreviewRequest, + SimTopUpPricingPreviewResponse, } from './schema'; // Provider adapters diff --git a/packages/domain/sim/schema.ts b/packages/domain/sim/schema.ts index 5856ce34..a9736387 100644 --- a/packages/domain/sim/schema.ts +++ b/packages/domain/sim/schema.ts @@ -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; + +/** + * 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; + +/** + * 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; + export const simPlanChangeRequestSchema = z.object({ newPlanCode: z.string().min(1, "New plan code is required"), assignGlobalIp: z.boolean().optional(),