Add SIM management features and enhance checkout process

- Introduced new SIM management queue and processor to handle SIM-related tasks efficiently.
- Added SIM activation fee handling in the checkout service, ensuring proper validation and inclusion in cart calculations.
- Enhanced the SimCatalogService to retrieve and filter activation fees based on SKU, improving order validation.
- Updated the checkout process to automatically add default activation fees when none are specified, improving user experience.
- Refactored the ActivationForm component to display activation fees clearly during the SIM configuration process.
- Improved error handling and logging across various services to provide better insights during operations.
- Updated tests to cover new features and ensure reliability in the checkout and SIM management workflows.
This commit is contained in:
barsa 2025-11-18 10:57:36 +09:00
parent fc2c46b21e
commit c741ece844
30 changed files with 1476 additions and 609 deletions

View File

@ -2,6 +2,7 @@ export const QUEUE_NAMES = {
EMAIL: "email",
PROVISIONING: "provisioning",
RECONCILE: "reconcile",
SIM_MANAGEMENT: "sim-management",
} as const;
export type QueueName = (typeof QUEUE_NAMES)[keyof typeof QUEUE_NAMES];

View File

@ -33,7 +33,8 @@ function parseRedisConnection(redisUrl: string) {
BullModule.registerQueue(
{ name: QUEUE_NAMES.EMAIL },
{ name: QUEUE_NAMES.PROVISIONING },
{ name: QUEUE_NAMES.RECONCILE }
{ name: QUEUE_NAMES.RECONCILE },
{ name: QUEUE_NAMES.SIM_MANAGEMENT }
),
],
exports: [BullModule],

View File

@ -67,16 +67,46 @@ export class SimCatalogService extends BaseCatalogService {
return this.catalogCache.getCachedCatalog(
cacheKey,
async () => {
const soql = this.buildProductQuery("SIM", "Activation", []);
const soql = this.buildProductQuery("SIM", "Activation", [
"Catalog_Order__c",
"Auto_Add__c",
"Is_Default__c",
]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql,
"SIM Activation Fees"
);
return records.map(record => {
const entry = this.extractPricebookEntry(record);
return CatalogProviders.Salesforce.mapSimActivationFee(record, entry);
});
const activationFees = records
.map(record => {
const entry = this.extractPricebookEntry(record);
return CatalogProviders.Salesforce.mapSimActivationFee(record, entry);
})
.sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0));
if (activationFees.length === 0) {
this.logger.warn("No SIM activation fees found in catalog");
return activationFees;
}
const hasDefault = activationFees.some(
fee => fee.catalogMetadata?.isDefault === true
);
if (!hasDefault) {
this.logger.warn(
"No default SIM activation fee configured. Marking the first fee as default."
);
activationFees[0] = {
...activationFees[0],
catalogMetadata: {
...activationFees[0].catalogMetadata,
isDefault: true,
},
};
}
return activationFees;
},
{
resolveDependencies: products => ({

View File

@ -10,6 +10,7 @@ import type { VpnCatalogService } from "@bff/modules/catalog/services/vpn-catalo
const createLogger = (): Logger =>
({
log: jest.fn(),
warn: jest.fn(),
error: jest.fn(),
}) as unknown as Logger;
@ -22,6 +23,41 @@ const internetPlan = {
oneTimePrice: 0,
} as unknown;
const simPlan = {
id: "sim-1",
sku: "SIM-PLAN-1",
name: "SIM Plan",
description: "SIM Plan",
monthlyPrice: 4500,
oneTimePrice: 0,
} as unknown;
const defaultActivationFee = {
id: "act-1",
sku: "SIM-ACTIVATION-FEE",
name: "SIM Activation Fee",
description: "One-time fee",
monthlyPrice: 0,
oneTimePrice: 3000,
catalogMetadata: {
autoAdd: true,
isDefault: true,
},
} as unknown;
const alternateActivationFee = {
id: "act-2",
sku: "SIM-ACTIVATION-PREMIUM",
name: "SIM Premium Activation",
description: "Premium activation",
monthlyPrice: 0,
oneTimePrice: 5000,
catalogMetadata: {
autoAdd: false,
isDefault: false,
},
} as unknown;
const createService = ({
internet,
sim,
@ -122,3 +158,128 @@ describe("CheckoutService - personalized carts", () => {
expect(internetCatalogService.getPlansForUser).not.toHaveBeenCalled();
});
});
describe("CheckoutService - SIM activation fees", () => {
const internetCatalogService = {
getPlansForUser: jest.fn(),
getPlans: jest.fn(),
getInstallations: jest.fn(),
getAddons: jest.fn(),
};
const vpnCatalogService = {
getPlans: jest.fn(),
getActivationFees: jest.fn(),
};
it("auto-adds default activation fee when none specified", async () => {
const simCatalogService = {
getPlans: jest.fn().mockResolvedValue([simPlan]),
getPlansForUser: jest.fn(),
getAddons: jest.fn().mockResolvedValue([]),
getActivationFees: jest.fn().mockResolvedValue([defaultActivationFee]),
};
const service = createService({
internet: internetCatalogService,
sim: simCatalogService,
vpn: vpnCatalogService,
});
const cart = await service.buildCart(ORDER_TYPE.SIM, { planSku: "SIM-PLAN-1" });
expect(cart.items).toEqual(
expect.arrayContaining([
expect.objectContaining({ sku: "SIM-PLAN-1" }),
expect.objectContaining({
sku: "SIM-ACTIVATION-FEE",
itemType: "activation",
autoAdded: true,
}),
])
);
expect(cart.totals.oneTimeTotal).toBe(3000);
});
it("respects explicit activation fee selection", async () => {
const simCatalogService = {
getPlans: jest.fn().mockResolvedValue([simPlan]),
getPlansForUser: jest.fn(),
getAddons: jest.fn().mockResolvedValue([]),
getActivationFees: jest
.fn()
.mockResolvedValue([defaultActivationFee, alternateActivationFee]),
};
const service = createService({
internet: internetCatalogService,
sim: simCatalogService,
vpn: vpnCatalogService,
});
const cart = await service.buildCart(ORDER_TYPE.SIM, {
planSku: "SIM-PLAN-1",
activationFeeSku: "SIM-ACTIVATION-PREMIUM",
});
expect(cart.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
sku: "SIM-ACTIVATION-PREMIUM",
autoAdded: false,
}),
])
);
expect(cart.totals.oneTimeTotal).toBe(5000);
});
it("throws when no activation fee is available", async () => {
const simCatalogService = {
getPlans: jest.fn().mockResolvedValue([simPlan]),
getPlansForUser: jest.fn(),
getAddons: jest.fn().mockResolvedValue([]),
getActivationFees: jest.fn().mockResolvedValue([]),
};
const service = createService({
internet: internetCatalogService,
sim: simCatalogService,
vpn: vpnCatalogService,
});
await expect(
service.buildCart(ORDER_TYPE.SIM, { planSku: "SIM-PLAN-1" })
).rejects.toThrow("SIM activation fee is not available");
});
it("skips activation fees without SKUs and falls back to the next valid option", async () => {
const simCatalogService = {
getPlans: jest.fn().mockResolvedValue([simPlan]),
getPlansForUser: jest.fn(),
getAddons: jest.fn().mockResolvedValue([]),
getActivationFees: jest.fn().mockResolvedValue([
{ ...defaultActivationFee, sku: "" },
alternateActivationFee,
]),
};
const service = createService({
internet: internetCatalogService,
sim: simCatalogService,
vpn: vpnCatalogService,
});
const cart = await service.buildCart(ORDER_TYPE.SIM, { planSku: "SIM-PLAN-1" });
expect(cart.items).toEqual(
expect.arrayContaining([
expect.objectContaining({
sku: "SIM-ACTIVATION-PREMIUM",
itemType: "activation",
autoAdded: true,
}),
])
);
expect(cart.totals.oneTimeTotal).toBe(5000);
});
});

View File

@ -78,6 +78,11 @@ export class CheckoutService {
this.logger.log("Checkout cart built successfully", {
itemCount: validatedCart.items.length,
items: validatedCart.items.map(item => ({
sku: item.sku,
name: item.name,
itemType: item.itemType,
})),
monthlyTotal: validatedCart.totals.monthlyTotal,
oneTimeTotal: validatedCart.totals.oneTimeTotal,
});
@ -225,8 +230,9 @@ export class CheckoutService {
const plans: SimCatalogProduct[] = userId
? await this.simCatalogService.getPlansForUser(userId)
: await this.simCatalogService.getPlans();
const activationFees: SimActivationFeeCatalogItem[] =
const rawActivationFees: SimActivationFeeCatalogItem[] =
await this.simCatalogService.getActivationFees();
const activationFees = this.filterActivationFeesWithSku(rawActivationFees);
const addons: SimCatalogProduct[] = await this.simCatalogService.getAddons();
// Add main plan
@ -250,27 +256,43 @@ export class CheckoutService {
itemType: "plan",
});
// Add activation fee
const simType = selections.simType || "eSIM";
const activation = activationFees.find(fee => {
const metadata = fee.catalogMetadata as { simType?: string } | undefined;
const feeSimType = metadata?.simType;
return feeSimType ? feeSimType === simType : fee.sku === selections.activationFeeSku;
// Add activation fee (required)
this.logger.debug("Activation fees available", {
rawActivationFeeCount: rawActivationFees.length,
sanitizedActivationFeeCount: activationFees.length,
activationFees: activationFees.map(f => ({ id: f.id, sku: f.sku, name: f.name })),
});
if (activation) {
items.push({
id: activation.id,
sku: activation.sku,
name: activation.name,
description: activation.description,
monthlyPrice: activation.monthlyPrice,
oneTimePrice: activation.oneTimePrice,
quantity: 1,
itemType: "activation",
const activationSelection = this.resolveSimActivationFee(activationFees, selections);
if (!activationSelection) {
this.logger.error("SIM activation fee is not available", {
planSku: selections.planSku,
rawActivationFeeCount: rawActivationFees.length,
sanitizedActivationFeeCount: activationFees.length,
});
throw new BadRequestException("SIM activation fee is not available");
}
const { fee: activation, autoAdded } = activationSelection;
this.logger.debug("Adding activation fee to cart", {
id: activation.id,
sku: activation.sku,
name: activation.name,
autoAdded,
});
items.push({
id: activation.id,
sku: activation.sku,
name: activation.name,
description: activation.description,
monthlyPrice: activation.monthlyPrice,
oneTimePrice: activation.oneTimePrice,
quantity: 1,
itemType: "activation",
autoAdded,
});
// Add addons
const addonRefs = this.collectAddonRefs(selections);
for (const ref of addonRefs) {
@ -341,6 +363,57 @@ export class CheckoutService {
return { items };
}
/**
* Resolve the correct SIM activation fee to include in the cart
*/
private filterActivationFeesWithSku(
activationFees: SimActivationFeeCatalogItem[]
): SimActivationFeeCatalogItem[] {
return activationFees.filter(fee => {
const hasSku = typeof fee.sku === "string" && fee.sku.trim().length > 0;
if (!hasSku) {
this.logger.warn("Skipping SIM activation fee without SKU", {
activationId: fee.id,
name: fee.name,
});
}
return hasSku;
});
}
private resolveSimActivationFee(
activationFees: SimActivationFeeCatalogItem[],
selections: OrderSelections
): { fee: SimActivationFeeCatalogItem; autoAdded: boolean } | null {
if (!Array.isArray(activationFees) || activationFees.length === 0) {
return null;
}
const findByRef = (ref?: string | null): SimActivationFeeCatalogItem | undefined => {
if (!ref) {
return undefined;
}
return activationFees.find(fee => fee.sku === ref || fee.id === ref);
};
const explicitFee = findByRef(selections.activationFeeSku);
if (explicitFee) {
return { fee: explicitFee, autoAdded: false };
}
const autoAddFee = activationFees.find(fee => fee.catalogMetadata?.autoAdd);
if (autoAddFee) {
return { fee: autoAddFee, autoAdded: true };
}
const defaultFee = activationFees.find(fee => fee.catalogMetadata?.isDefault);
if (defaultFee) {
return { fee: defaultFee, autoAdded: true };
}
return { fee: activationFees[0], autoAdded: true };
}
/**
* Collect addon references from selections
*/

View File

@ -13,7 +13,7 @@ import {
sanitizeSoqlLiteral,
} from "@bff/integrations/salesforce/utils/soql.util";
interface PricebookProductMeta {
export interface PricebookProductMeta {
sku: string;
pricebookEntryId: string;
product2Id?: string;

View File

@ -13,7 +13,8 @@ import {
import type { Providers } from "@customer-portal/domain/subscriptions";
type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw;
import { OrderPricebookService } from "./order-pricebook.service";
import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service";
import { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service";
import { PaymentValidatorService } from "./payment-validator.service";
/**
@ -29,6 +30,7 @@ export class OrderValidator {
private readonly mappings: MappingsService,
private readonly whmcs: WhmcsConnectionOrchestratorService,
private readonly pricebookService: OrderPricebookService,
private readonly simCatalogService: SimCatalogService,
private readonly paymentValidator: PaymentValidatorService
) {}
@ -186,7 +188,10 @@ export class OrderValidator {
/**
* Validate SKUs exist in Salesforce
*/
async validateSKUs(skus: string[], pricebookId: string): Promise<void> {
async validateSKUs(
skus: string[],
pricebookId: string
): Promise<Map<string, PricebookProductMeta>> {
const invalidSKUs: string[] = [];
const meta = await this.pricebookService.fetchProductMeta(pricebookId, skus);
const normalizedSkus = skus
@ -204,6 +209,8 @@ export class OrderValidator {
this.logger.error({ invalidSKUs }, "Invalid SKUs found in order");
throw new BadRequestException(`Invalid products: ${invalidSKUs.join(", ")}`);
}
return meta;
}
/**
@ -236,6 +243,14 @@ export class OrderValidator {
const path = issue.path.join(".");
return path ? `${path}: ${issue.message}` : issue.message;
});
this.logger.error("Business validation failed", {
userId,
orderType: validatedBody.orderType,
skus: validatedBody.skus,
validationErrors: issues,
});
throw new BadRequestException({
message: "Order business validation failed",
errors: issues,
@ -251,7 +266,34 @@ export class OrderValidator {
// 3. SKU validation
const pricebookId = await this.pricebookService.findPortalPricebookId();
await this.validateSKUs(businessValidatedBody.skus, pricebookId);
const productMeta = await this.validateSKUs(businessValidatedBody.skus, pricebookId);
if (businessValidatedBody.orderType === "SIM") {
const activationFees = await this.simCatalogService.getActivationFees();
const activationSkus = new Set(
activationFees
.map(fee => fee.sku?.trim())
.filter((sku): sku is string => Boolean(sku))
.map(sku => sku.toUpperCase())
);
const hasActivationSku = businessValidatedBody.skus.some(sku => {
const normalized = sku?.trim().toUpperCase();
if (!normalized) {
return false;
}
return activationSkus.has(normalized);
});
if (!hasActivationSku) {
this.logger.warn(
{
skus: businessValidatedBody.skus,
},
"SIM order missing activation SKU based on catalog metadata"
);
throw new BadRequestException("SIM orders require an activation fee");
}
}
// 4. Order-specific business validation
if (businessValidatedBody.orderType === "Internet") {

View File

@ -0,0 +1,57 @@
import { Processor, WorkerHost } from "@nestjs/bullmq";
import { Inject, Injectable } from "@nestjs/common";
import type { Job } from "bullmq";
import { Logger } from "nestjs-pino";
import { QUEUE_NAMES } from "@bff/infra/queue/queue.constants";
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import {
SIM_MANAGEMENT_JOB_NAMES as JOB_NAMES,
type NetworkTypeChangeJob,
} from "./sim-management.queue";
@Processor(QUEUE_NAMES.SIM_MANAGEMENT)
@Injectable()
export class SimManagementProcessor extends WorkerHost {
constructor(
private readonly freebitService: FreebitOrchestratorService,
@Inject(Logger) private readonly logger: Logger
) {
super();
}
async process(job: Job<NetworkTypeChangeJob>): Promise<void> {
switch (job.name) {
case JOB_NAMES.NETWORK_TYPE_CHANGE:
await this.handleNetworkTypeChange(job.data);
return;
default:
this.logger.warn("Received unknown SIM management job", {
jobName: job.name,
});
}
}
private async handleNetworkTypeChange(payload: NetworkTypeChangeJob): Promise<void> {
try {
await this.freebitService.updateSimFeatures(payload.account, {
networkType: payload.networkType,
});
this.logger.log("Processed deferred network type change", {
account: payload.account,
subscriptionId: payload.subscriptionId,
userId: payload.userId,
networkType: payload.networkType,
});
} catch (error) {
this.logger.error("Deferred network type change failed", {
error: getErrorMessage(error),
account: payload.account,
subscriptionId: payload.subscriptionId,
networkType: payload.networkType,
});
throw error;
}
}
}

View File

@ -0,0 +1,50 @@
import { Inject, Injectable } from "@nestjs/common";
import { InjectQueue } from "@nestjs/bullmq";
import { Queue } from "bullmq";
import { Logger } from "nestjs-pino";
import { QUEUE_NAMES } from "@bff/infra/queue/queue.constants";
export type NetworkTypeChangeJob = {
account: string;
networkType: "4G" | "5G";
userId: string;
subscriptionId: number;
};
export const SIM_MANAGEMENT_JOB_NAMES = {
NETWORK_TYPE_CHANGE: "network-type-change",
} as const;
@Injectable()
export class SimManagementQueueService {
constructor(
@InjectQueue(QUEUE_NAMES.SIM_MANAGEMENT) private readonly queue: Queue<NetworkTypeChangeJob>,
@Inject(Logger) private readonly logger: Logger
) {}
async scheduleNetworkTypeChange(
data: NetworkTypeChangeJob,
delayMs = 30 * 60 * 1000
): Promise<void> {
await this.queue.add(SIM_MANAGEMENT_JOB_NAMES.NETWORK_TYPE_CHANGE, data, {
delay: delayMs,
removeOnComplete: 100,
removeOnFail: 50,
attempts: 3,
backoff: {
type: "exponential",
delay: 5000,
},
jobId: `${SIM_MANAGEMENT_JOB_NAMES.NETWORK_TYPE_CHANGE}:${data.account}:${Date.now()}`,
});
this.logger.log("Scheduled deferred SIM network type change", {
account: data.account,
subscriptionId: data.subscriptionId,
userId: data.userId,
networkType: data.networkType,
delayMs,
});
}
}

View File

@ -0,0 +1,40 @@
import { Injectable } from "@nestjs/common";
import { getErrorMessage } from "@bff/core/utils/error.util";
import { SimNotificationService } from "./sim-notification.service";
import type { SimNotificationContext } from "../interfaces/sim-base.interface";
interface RunOptions<T> {
baseContext: SimNotificationContext;
enrichSuccess?: (result: T) => Partial<SimNotificationContext>;
enrichError?: (error: unknown) => Partial<SimNotificationContext>;
}
@Injectable()
export class SimActionRunnerService {
constructor(private readonly simNotification: SimNotificationService) {}
async run<T>(
action: string,
options: RunOptions<T>,
handler: () => Promise<T>
): Promise<T> {
try {
const result = await handler();
const successContext = {
...options.baseContext,
...(options.enrichSuccess ? options.enrichSuccess(result) : {}),
};
await this.simNotification.notifySimAction(action, "SUCCESS", successContext);
return result;
} catch (error) {
const errorContext = {
...options.baseContext,
error: getErrorMessage(error),
...(options.enrichError ? options.enrichError(error) : {}),
};
await this.simNotification.notifySimAction(action, "ERROR", errorContext);
throw error;
}
}
}

View File

@ -0,0 +1,108 @@
import { BadRequestException, Inject, Injectable } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
export interface SimChargeInvoiceResult {
invoice: { id: number; number: string; total: number; status: string };
transactionId?: string;
}
interface OneTimeChargeParams {
clientId: number;
description: string;
amountJpy: number;
currency?: string;
dueDate?: Date;
notes?: string;
failureNotesPrefix?: string;
publicErrorMessage?: string;
metadata?: Record<string, unknown>;
}
@Injectable()
export class SimBillingService {
constructor(private readonly whmcs: WhmcsService, @Inject(Logger) private readonly logger: Logger) {}
async createOneTimeCharge(params: OneTimeChargeParams): Promise<SimChargeInvoiceResult> {
const {
clientId,
description,
amountJpy,
currency = "JPY",
dueDate,
notes,
failureNotesPrefix = "Payment failed",
publicErrorMessage,
metadata = {},
} = params;
const invoice = await this.whmcs.createInvoice({
clientId,
description,
amount: amountJpy,
currency,
dueDate,
notes,
});
const paymentResult = await this.whmcs.capturePayment({
invoiceId: invoice.id,
amount: amountJpy,
currency,
});
if (!paymentResult.success) {
const failureMessage = `${failureNotesPrefix}: ${paymentResult.error || "unknown error"}`;
await this.cancelInvoice(invoice.id, failureMessage);
this.logger.error("SIM billing payment failed", {
invoiceId: invoice.id,
clientId,
description,
amountJpy,
...metadata,
error: paymentResult.error,
});
throw new BadRequestException(publicErrorMessage ?? failureMessage);
}
this.logger.log("SIM billing payment captured", {
invoiceId: invoice.id,
clientId,
description,
amountJpy,
transactionId: paymentResult.transactionId,
...metadata,
});
return {
invoice,
transactionId: paymentResult.transactionId,
};
}
async appendInvoiceNote(invoiceId: number, note: string): Promise<void> {
await this.whmcs.updateInvoice({
invoiceId,
notes: note,
});
}
private async cancelInvoice(invoiceId: number, reason: string): Promise<void> {
try {
await this.whmcs.updateInvoice({
invoiceId,
status: "Cancelled",
notes: reason,
});
} catch (error) {
this.logger.error("Failed to cancel invoice after payment failure", {
invoiceId,
error: getErrorMessage(error),
});
}
}
}

View File

@ -2,16 +2,18 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
import { SimValidationService } from "./sim-validation.service";
import { SimNotificationService } from "./sim-notification.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SimCancelRequest } from "@customer-portal/domain/sim";
import { SimScheduleService } from "./sim-schedule.service";
import { SimActionRunnerService } from "./sim-action-runner.service";
@Injectable()
export class SimCancellationService {
constructor(
private readonly freebitService: FreebitOrchestratorService,
private readonly simValidation: SimValidationService,
private readonly simNotification: SimNotificationService,
private readonly simSchedule: SimScheduleService,
private readonly simActionRunner: SimActionRunnerService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -23,52 +25,44 @@ export class SimCancellationService {
subscriptionId: number,
request: SimCancelRequest = {}
): Promise<void> {
try {
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
let account = "";
// Determine run date (PA02-04 requires runDate); default to 1st of next month
let runDate = request.scheduledAt;
if (runDate && !/^\d{8}$/.test(runDate)) {
throw new BadRequestException("Scheduled date must be in YYYYMMDD format");
await this.simActionRunner.run(
"Cancel SIM",
{
baseContext: {
userId,
subscriptionId,
scheduledAt: request.scheduledAt,
},
enrichSuccess: result => ({
account: result.account,
runDate: result.runDate,
}),
enrichError: () => ({
account,
}),
},
async () => {
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
account = validation.account;
const scheduleResolution = this.simSchedule.resolveScheduledDate(request.scheduledAt);
await this.freebitService.cancelSim(account, scheduleResolution.date);
this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, {
userId,
subscriptionId,
account,
runDate: scheduleResolution.date,
});
return {
account,
runDate: scheduleResolution.date,
};
}
if (!runDate) {
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
nextMonth.setDate(1);
const y = nextMonth.getFullYear();
const m = String(nextMonth.getMonth() + 1).padStart(2, "0");
const d = String(nextMonth.getDate()).padStart(2, "0");
runDate = `${y}${m}${d}`;
}
await this.freebitService.cancelSim(account, runDate);
this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, {
userId,
subscriptionId,
account,
runDate,
});
await this.simNotification.notifySimAction("Cancel SIM", "SUCCESS", {
userId,
subscriptionId,
account,
runDate,
});
} catch (error) {
const sanitizedError = getErrorMessage(error);
this.logger.error(`Failed to cancel SIM for subscription ${subscriptionId}`, {
error: sanitizedError,
userId,
subscriptionId,
});
await this.simNotification.notifySimAction("Cancel SIM", "ERROR", {
userId,
subscriptionId,
error: sanitizedError,
});
throw error;
}
);
}
}

View File

@ -2,16 +2,20 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
import { SimValidationService } from "./sim-validation.service";
import { SimNotificationService } from "./sim-notification.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SimPlanChangeRequest, SimFeaturesUpdateRequest } from "@customer-portal/domain/sim";
import { SimScheduleService } from "./sim-schedule.service";
import { SimActionRunnerService } from "./sim-action-runner.service";
import { SimManagementQueueService } from "../queue/sim-management.queue";
@Injectable()
export class SimPlanService {
constructor(
private readonly freebitService: FreebitOrchestratorService,
private readonly simValidation: SimValidationService,
private readonly simNotification: SimNotificationService,
private readonly simSchedule: SimScheduleService,
private readonly simActionRunner: SimActionRunnerService,
private readonly simQueue: SimManagementQueueService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -23,91 +27,72 @@ export class SimPlanService {
subscriptionId: number,
request: SimPlanChangeRequest
): Promise<{ ipv4?: string; ipv6?: string }> {
let account = "";
const assignGlobalIp = request.assignGlobalIp ?? false;
let scheduledAt: string | undefined;
try {
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
const response = await this.simActionRunner.run(
"Change Plan",
{
baseContext: {
userId,
subscriptionId,
newPlanCode: request.newPlanCode,
assignGlobalIp,
},
enrichSuccess: result => ({
account: result.account,
scheduledAt: result.scheduledAt,
}),
enrichError: () => ({
account,
}),
},
async () => {
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
account = validation.account;
// Validate plan code format
if (!request.newPlanCode || request.newPlanCode.length < 3) {
throw new BadRequestException("Invalid plan code");
}
scheduledAt = request.scheduledAt;
if (scheduledAt) {
if (!/^\d{8}$/.test(scheduledAt)) {
throw new BadRequestException("scheduledAt must be in YYYYMMDD format");
if (!request.newPlanCode || request.newPlanCode.length < 3) {
throw new BadRequestException("Invalid plan code");
}
} else {
// Default to the 1st of the next month if no schedule provided.
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
nextMonth.setDate(1);
const year = nextMonth.getFullYear();
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
const day = String(nextMonth.getDate()).padStart(2, "0");
scheduledAt = `${year}${month}${day}`;
const scheduleResolution = this.simSchedule.resolveScheduledDate(request.scheduledAt);
this.logger.log("Submitting SIM plan change request", {
userId,
subscriptionId,
account,
newPlanCode: request.newPlanCode,
scheduledAt: scheduleResolution.date,
assignGlobalIp,
scheduleOrigin: scheduleResolution.source,
});
const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, {
assignGlobalIp,
scheduledAt: scheduleResolution.date,
});
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
userId,
subscriptionId,
account,
newPlanCode: request.newPlanCode,
scheduledAt: scheduleResolution.date,
assignGlobalIp,
});
return {
...result,
account,
scheduledAt: scheduleResolution.date,
};
}
);
this.logger.log("Submitting SIM plan change request", {
userId,
subscriptionId,
account,
newPlanCode: request.newPlanCode,
scheduledAt,
assignGlobalIp,
scheduleOrigin: request.scheduledAt ? "user-provided" : "auto-default",
});
if (!scheduledAt) {
throw new BadRequestException("Failed to determine schedule date for plan change");
}
const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, {
assignGlobalIp,
scheduledAt,
});
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
userId,
subscriptionId,
account,
newPlanCode: request.newPlanCode,
scheduledAt,
assignGlobalIp,
});
await this.simNotification.notifySimAction("Change Plan", "SUCCESS", {
userId,
subscriptionId,
account,
newPlanCode: request.newPlanCode,
scheduledAt,
assignGlobalIp,
});
return result;
} catch (error) {
const sanitizedError = getErrorMessage(error);
this.logger.error(`Failed to change SIM plan for subscription ${subscriptionId}`, {
error: sanitizedError,
userId,
subscriptionId,
newPlanCode: request.newPlanCode,
assignGlobalIp,
scheduledAt,
});
await this.simNotification.notifySimAction("Change Plan", "ERROR", {
userId,
subscriptionId,
newPlanCode: request.newPlanCode,
assignGlobalIp,
scheduledAt,
error: sanitizedError,
});
throw error;
}
return {
ipv4: response.ipv4,
ipv6: response.ipv6,
};
}
/**
@ -118,93 +103,68 @@ export class SimPlanService {
subscriptionId: number,
request: SimFeaturesUpdateRequest
): Promise<void> {
try {
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
let account = "";
// Validate network type if provided
if (request.networkType && !["4G", "5G"].includes(request.networkType)) {
throw new BadRequestException('networkType must be either "4G" or "5G"');
}
await this.simActionRunner.run(
"Update Features",
{
baseContext: {
userId,
subscriptionId,
...request,
},
enrichSuccess: () => ({
account,
}),
enrichError: () => ({
account,
}),
},
async () => {
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
account = validation.account;
const doVoice =
typeof request.voiceMailEnabled === "boolean" ||
typeof request.callWaitingEnabled === "boolean" ||
typeof request.internationalRoamingEnabled === "boolean";
const doContract = typeof request.networkType === "string";
if (request.networkType && !["4G", "5G"].includes(request.networkType)) {
throw new BadRequestException('networkType must be either "4G" or "5G"');
}
if (doVoice && doContract) {
// First apply voice options immediately (PA05-06)
await this.freebitService.updateSimFeatures(account, {
voiceMailEnabled: request.voiceMailEnabled,
callWaitingEnabled: request.callWaitingEnabled,
internationalRoamingEnabled: request.internationalRoamingEnabled,
});
const doVoice =
typeof request.voiceMailEnabled === "boolean" ||
typeof request.callWaitingEnabled === "boolean" ||
typeof request.internationalRoamingEnabled === "boolean";
const doContract = typeof request.networkType === "string";
// Then schedule contract line change after 30 minutes (PA05-38)
const delayMs = 30 * 60 * 1000;
setTimeout(() => {
this.freebitService
.updateSimFeatures(account, { networkType: request.networkType })
.then(() =>
this.logger.log("Deferred contract line change executed after 30 minutes", {
userId,
subscriptionId,
account,
networkType: request.networkType,
})
)
.catch(err =>
this.logger.error("Deferred contract line change failed", {
error: getErrorMessage(err),
userId,
subscriptionId,
account,
})
);
}, delayMs);
if (doVoice && doContract) {
await this.freebitService.updateSimFeatures(account, {
voiceMailEnabled: request.voiceMailEnabled,
callWaitingEnabled: request.callWaitingEnabled,
internationalRoamingEnabled: request.internationalRoamingEnabled,
});
this.logger.log("Scheduled contract line change 30 minutes after voice option change", {
await this.simQueue.scheduleNetworkTypeChange({
account,
networkType: request.networkType as "4G" | "5G",
userId,
subscriptionId,
});
this.logger.log("Scheduled contract line change via queue after voice option change", {
userId,
subscriptionId,
account,
networkType: request.networkType,
});
} else {
await this.freebitService.updateSimFeatures(account, request);
}
this.logger.log(`Updated SIM features for subscription ${subscriptionId}`, {
userId,
subscriptionId,
account,
networkType: request.networkType,
...request,
});
} else {
await this.freebitService.updateSimFeatures(account, request);
}
this.logger.log(`Updated SIM features for subscription ${subscriptionId}`, {
userId,
subscriptionId,
account,
...request,
});
await this.simNotification.notifySimAction("Update Features", "SUCCESS", {
userId,
subscriptionId,
account,
...request,
note:
doVoice && doContract
? "Voice options applied immediately; contract line change scheduled after 30 minutes"
: undefined,
});
} catch (error) {
const sanitizedError = getErrorMessage(error);
this.logger.error(`Failed to update SIM features for subscription ${subscriptionId}`, {
error: sanitizedError,
userId,
subscriptionId,
...request,
});
await this.simNotification.notifySimAction("Update Features", "ERROR", {
userId,
subscriptionId,
...request,
error: sanitizedError,
});
throw error;
}
);
}
}

View File

@ -0,0 +1,72 @@
import { BadRequestException, Injectable } from "@nestjs/common";
type ScheduleResolution = {
date: string;
source: "user" | "auto";
};
@Injectable()
export class SimScheduleService {
private static readonly DATE_REGEX = /^\d{8}$/;
ensureYyyyMmDd(value: string | undefined, fieldName = "date"): string {
if (!value) {
throw new BadRequestException(`${fieldName} is required in YYYYMMDD format`);
}
if (!SimScheduleService.DATE_REGEX.test(value)) {
throw new BadRequestException(`${fieldName} must be in YYYYMMDD format`);
}
return value;
}
validateOptionalYyyyMmDd(value: string | undefined, fieldName = "date"): string | undefined {
if (value == null) {
return undefined;
}
if (!SimScheduleService.DATE_REGEX.test(value)) {
throw new BadRequestException(`${fieldName} must be in YYYYMMDD format`);
}
return value;
}
resolveScheduledDate(value?: string, fieldName = "scheduledAt"): ScheduleResolution {
if (value) {
return {
date: this.ensureYyyyMmDd(value, fieldName),
source: "user",
};
}
return {
date: this.firstDayOfNextMonthYyyyMmDd(),
source: "auto",
};
}
firstDayOfNextMonth(): Date {
const nextMonth = new Date();
nextMonth.setHours(0, 0, 0, 0);
nextMonth.setMonth(nextMonth.getMonth() + 1);
nextMonth.setDate(1);
return nextMonth;
}
firstDayOfNextMonthYyyyMmDd(): string {
return this.formatYyyyMmDd(this.firstDayOfNextMonth());
}
firstDayOfNextMonthIsoDate(): string {
return this.formatIsoDate(this.firstDayOfNextMonth());
}
formatYyyyMmDd(date: Date): string {
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, "0");
const day = String(date.getDate()).padStart(2, "0");
return `${year}${month}${day}`;
}
formatIsoDate(date: Date): string {
return date.toISOString().split("T")[0];
}
}

View File

@ -1,21 +1,21 @@
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
import { Logger } from "nestjs-pino";
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { SimValidationService } from "./sim-validation.service";
import { SimNotificationService } from "./sim-notification.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SimTopUpRequest } from "@customer-portal/domain/sim";
import { SimBillingService } from "./sim-billing.service";
import { SimActionRunnerService } from "./sim-action-runner.service";
@Injectable()
export class SimTopUpService {
constructor(
private readonly freebitService: FreebitOrchestratorService,
private readonly whmcsService: WhmcsService,
private readonly mappingsService: MappingsService,
private readonly simValidation: SimValidationService,
private readonly simNotification: SimNotificationService,
private readonly simBilling: SimBillingService,
private readonly simActionRunner: SimActionRunnerService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -24,171 +24,102 @@ export class SimTopUpService {
* Pricing: 1GB = 500 JPY
*/
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
let account: string = "";
let latestAccount = "";
try {
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
account = validation.account;
// Validate quota amount
if (request.quotaMb <= 0 || request.quotaMb > 100000) {
throw new BadRequestException("Quota must be between 1MB and 100GB");
}
// Calculate cost: 1GB = 500 JPY (rounded up to nearest GB)
const quotaGb = request.quotaMb / 1000;
const units = Math.ceil(quotaGb);
const costJpy = units * 500;
// Validate quota against Freebit API limits (100MB - 51200MB)
if (request.quotaMb < 100 || request.quotaMb > 51200) {
throw new BadRequestException(
"Quota must be between 100MB and 51200MB (50GB) for Freebit API compatibility"
);
}
// Get client mapping for WHMCS
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new BadRequestException("WHMCS client mapping not found");
}
const whmcsClientId = mapping.whmcsClientId;
this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, {
userId,
subscriptionId,
account,
quotaMb: request.quotaMb,
quotaGb: quotaGb.toFixed(2),
costJpy,
});
// Step 1: Create WHMCS invoice
const invoice = await this.whmcsService.createInvoice({
clientId: whmcsClientId,
description: `SIM Data Top-up: ${units}GB for ${account}`,
amount: costJpy,
currency: "JPY",
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
notes: `Subscription ID: ${subscriptionId}, Phone: ${account}`,
});
this.logger.log(`Created WHMCS invoice ${invoice.id} for SIM top-up`, {
invoiceId: invoice.id,
invoiceNumber: invoice.number,
amount: costJpy,
subscriptionId,
});
// Step 2: Capture payment
this.logger.log(`Attempting payment capture`, {
invoiceId: invoice.id,
amount: costJpy,
});
const paymentResult = await this.whmcsService.capturePayment({
invoiceId: invoice.id,
amount: costJpy,
currency: "JPY",
});
if (!paymentResult.success) {
this.logger.error(`Payment capture failed for invoice ${invoice.id}`, {
invoiceId: invoice.id,
error: paymentResult.error,
await this.simActionRunner.run(
"Top Up Data",
{
baseContext: {
userId,
subscriptionId,
quotaMb: request.quotaMb,
},
enrichSuccess: meta => ({
account: meta.account,
costJpy: meta.costJpy,
invoiceId: meta.invoiceId,
transactionId: meta.transactionId,
}),
enrichError: () => ({
account: latestAccount,
}),
},
async () => {
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
latestAccount = validation.account;
if (request.quotaMb <= 0 || request.quotaMb > 100000) {
throw new BadRequestException("Quota must be between 1MB and 100GB");
}
const quotaGb = request.quotaMb / 1000;
const units = Math.ceil(quotaGb);
const costJpy = units * 500;
if (request.quotaMb < 100 || request.quotaMb > 51200) {
throw new BadRequestException(
"Quota must be between 100MB and 51200MB (50GB) for Freebit API compatibility"
);
}
const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) {
throw new BadRequestException("WHMCS client mapping not found");
}
this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, {
userId,
subscriptionId,
account: latestAccount,
quotaMb: request.quotaMb,
quotaGb: quotaGb.toFixed(2),
costJpy,
});
// Cancel the invoice since payment failed
await this.handlePaymentFailure(invoice.id, paymentResult.error || "Unknown payment error");
const billing = await this.simBilling.createOneTimeCharge({
clientId: mapping.whmcsClientId,
description: `SIM Data Top-up: ${units}GB for ${latestAccount}`,
amountJpy: costJpy,
currency: "JPY",
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
notes: `Subscription ID: ${subscriptionId}, Phone: ${latestAccount}`,
failureNotesPrefix: "Payment capture failed",
publicErrorMessage: "SIM top-up failed: payment could not be processed",
metadata: { subscriptionId },
});
throw new BadRequestException(`SIM top-up failed: ${paymentResult.error}`);
}
this.logger.log(`Payment captured successfully for invoice ${invoice.id}`, {
invoiceId: invoice.id,
transactionId: paymentResult.transactionId,
amount: costJpy,
subscriptionId,
});
try {
// Step 3: Only if payment successful, add data via Freebit
await this.freebitService.topUpSim(account, request.quotaMb, {});
try {
await this.freebitService.topUpSim(latestAccount, request.quotaMb, {});
} catch (freebitError) {
await this.handleFreebitFailureAfterPayment(
freebitError,
billing.invoice,
billing.transactionId || "unknown",
userId,
subscriptionId,
latestAccount,
request.quotaMb
);
}
this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, {
userId,
subscriptionId,
account,
account: latestAccount,
quotaMb: request.quotaMb,
costJpy,
invoiceId: invoice.id,
transactionId: paymentResult.transactionId,
invoiceId: billing.invoice.id,
transactionId: billing.transactionId,
});
await this.simNotification.notifySimAction("Top Up Data", "SUCCESS", {
userId,
subscriptionId,
account,
quotaMb: request.quotaMb,
return {
account: latestAccount,
costJpy,
invoiceId: invoice.id,
transactionId: paymentResult.transactionId,
});
} catch (freebitError) {
// If Freebit fails after payment, handle carefully
await this.handleFreebitFailureAfterPayment(
freebitError,
invoice,
paymentResult.transactionId || "unknown",
userId,
subscriptionId,
account,
request.quotaMb
);
invoiceId: billing.invoice.id,
transactionId: billing.transactionId,
};
}
} catch (error) {
const sanitizedError = getErrorMessage(error);
this.logger.error(`Failed to top up SIM for subscription ${subscriptionId}`, {
error: sanitizedError,
userId,
subscriptionId,
quotaMb: request.quotaMb,
});
await this.simNotification.notifySimAction("Top Up Data", "ERROR", {
userId,
subscriptionId,
account: account ?? "",
quotaMb: request.quotaMb,
error: sanitizedError,
});
throw error;
}
}
/**
* Handle payment failure by canceling the invoice
*/
private async handlePaymentFailure(invoiceId: number, error: string): Promise<void> {
try {
await this.whmcsService.updateInvoice({
invoiceId,
status: "Cancelled",
notes: `Payment capture failed: ${error}. Invoice cancelled automatically.`,
});
this.logger.log(`Cancelled invoice ${invoiceId} due to payment failure`, {
invoiceId,
reason: "Payment capture failed",
});
} catch (cancelError) {
this.logger.error(`Failed to cancel invoice ${invoiceId} after payment failure`, {
invoiceId,
cancelError: getErrorMessage(cancelError),
originalError: error,
});
}
);
}
/**
@ -219,10 +150,12 @@ export class SimTopUpService {
// Add a note to the invoice about the Freebit failure
try {
await this.whmcsService.updateInvoice({
invoiceId: invoice.id,
notes: `Payment successful but SIM top-up failed: ${getErrorMessage(freebitError)}. Manual intervention required.`,
});
await this.simBilling.appendInvoiceNote(
invoice.id,
`Payment successful but SIM top-up failed: ${getErrorMessage(
freebitError
)}. Manual intervention required.`
);
this.logger.log(`Added failure note to invoice ${invoice.id}`, {
invoiceId: invoice.id,
@ -241,16 +174,8 @@ export class SimTopUpService {
// to ensure consistency across all failure scenarios.
// For manual refunds, use the WHMCS admin panel or dedicated refund endpoints.
const errMsg = `Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`;
await this.simNotification.notifySimAction("Top Up Data", "ERROR", {
userId,
subscriptionId,
account,
quotaMb,
invoiceId: invoice.id,
transactionId,
error: getErrorMessage(freebitError),
});
throw new Error(errMsg);
throw new Error(
`Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`
);
}
}

View File

@ -6,7 +6,7 @@ import { SimUsageStoreService } from "../../sim-usage-store.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim";
import type { SimTopUpHistoryRequest } from "@customer-portal/domain/sim";
import { BadRequestException } from "@nestjs/common";
import { SimScheduleService } from "./sim-schedule.service";
@Injectable()
export class SimUsageService {
@ -14,6 +14,7 @@ export class SimUsageService {
private readonly freebitService: FreebitOrchestratorService,
private readonly simValidation: SimValidationService,
private readonly usageStore: SimUsageStoreService,
private readonly simSchedule: SimScheduleService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -76,15 +77,13 @@ export class SimUsageService {
try {
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
// Validate date format
if (!/^\d{8}$/.test(request.fromDate) || !/^\d{8}$/.test(request.toDate)) {
throw new BadRequestException("Dates must be in YYYYMMDD format");
}
const fromDate = this.simSchedule.ensureYyyyMmDd(request.fromDate, "fromDate");
const toDate = this.simSchedule.ensureYyyyMmDd(request.toDate, "toDate");
const history = await this.freebitService.getSimTopUpHistory(
account,
request.fromDate,
request.toDate
fromDate,
toDate
);
this.logger.log(`Retrieved SIM top-up history for subscription ${subscriptionId}`, {

View File

@ -16,6 +16,11 @@ import { SimCancellationService } from "./services/sim-cancellation.service";
import { EsimManagementService } from "./services/esim-management.service";
import { SimValidationService } from "./services/sim-validation.service";
import { SimNotificationService } from "./services/sim-notification.service";
import { SimBillingService } from "./services/sim-billing.service";
import { SimScheduleService } from "./services/sim-schedule.service";
import { SimActionRunnerService } from "./services/sim-action-runner.service";
import { SimManagementQueueService } from "./queue/sim-management.queue";
import { SimManagementProcessor } from "./queue/sim-management.processor";
@Module({
imports: [FreebitModule, WhmcsModule, MappingsModule, EmailModule],
@ -34,6 +39,11 @@ import { SimNotificationService } from "./services/sim-notification.service";
SimCancellationService,
EsimManagementService,
SimOrchestratorService,
SimBillingService,
SimScheduleService,
SimActionRunnerService,
SimManagementQueueService,
SimManagementProcessor,
],
exports: [
SimOrchestratorService,
@ -46,6 +56,10 @@ import { SimNotificationService } from "./services/sim-notification.service";
EsimManagementService,
SimValidationService,
SimNotificationService,
SimBillingService,
SimScheduleService,
SimActionRunnerService,
SimManagementQueueService,
],
})
export class SimManagementModule {}

View File

@ -7,6 +7,8 @@ import { CacheService } from "@bff/infra/cache/cache.service";
import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SimOrderActivationRequest } from "@customer-portal/domain/sim";
import { randomUUID } from "crypto";
import { SimBillingService } from "./sim-management/services/sim-billing.service";
import { SimScheduleService } from "./sim-management/services/sim-schedule.service";
@Injectable()
export class SimOrderActivationService {
@ -15,6 +17,8 @@ export class SimOrderActivationService {
private readonly whmcs: WhmcsService,
private readonly mappings: MappingsService,
private readonly cache: CacheService,
private readonly simBilling: SimBillingService,
private readonly simSchedule: SimScheduleService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -51,69 +55,82 @@ export class SimOrderActivationService {
// Mark as processing (5 minute TTL)
await this.cache.set(processingKey, true, 300);
const releaseProcessingFlag = async () => {
await this.cache.del(processingKey);
};
if (req.simType === "eSIM" && (!req.eid || req.eid.length < 15)) {
await releaseProcessingFlag();
throw new BadRequestException("EID is required for eSIM and must be valid");
}
if (!req.msisdn || req.msisdn.trim() === "") {
await releaseProcessingFlag();
throw new BadRequestException("Phone number (msisdn) is required for SIM activation");
}
if (!/^\d{8}$/.test(req.scheduledAt || "") && req.activationType === "Scheduled") {
throw new BadRequestException("scheduledAt must be YYYYMMDD when scheduling activation");
try {
if (req.activationType === "Scheduled") {
this.simSchedule.ensureYyyyMmDd(req.scheduledAt, "scheduledAt");
} else {
this.simSchedule.validateOptionalYyyyMmDd(req.scheduledAt, "scheduledAt");
}
} catch (error) {
await releaseProcessingFlag();
throw error;
}
if (req.simType !== "eSIM") {
this.logger.warn("Physical SIM activation blocked", {
userId,
simType: req.simType,
msisdn: req.msisdn,
});
await releaseProcessingFlag();
throw new BadRequestException(
"Physical SIM activations are temporarily disabled. Please choose eSIM."
);
}
const mapping = await this.mappings.findByUserId(userId);
if (!mapping?.whmcsClientId) {
await releaseProcessingFlag();
throw new BadRequestException("WHMCS client mapping not found");
}
// 1) Create invoice for one-time activation fee only
const invoice = await this.whmcs.createInvoice({
clientId: mapping.whmcsClientId,
description: `SIM Activation Fee (${req.planSku}) for ${req.msisdn}`,
amount: req.oneTimeAmountJpy,
currency: "JPY",
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
notes: `SIM activation fee for ${req.msisdn}, plan ${req.planSku}. Monthly billing will start on the 1st of next month.`,
});
let billingResult:
| {
invoice: { id: number; number: string };
transactionId?: string;
}
| undefined;
const paymentResult = await this.whmcs.capturePayment({
invoiceId: invoice.id,
amount: req.oneTimeAmountJpy,
currency: "JPY",
});
if (!paymentResult.success) {
await this.whmcs.updateInvoice({
invoiceId: invoice.id,
status: "Cancelled",
notes: `Payment failed: ${paymentResult.error || "unknown"}`,
});
throw new BadRequestException(`Payment failed: ${paymentResult.error || "unknown"}`);
}
// 2) Freebit activation
try {
if (req.simType === "eSIM") {
await this.freebit.activateEsimAccountNew({
account: req.msisdn,
eid: req.eid!,
planCode: req.planSku,
contractLine: "5G",
shipDate: req.activationType === "Scheduled" ? req.scheduledAt : undefined,
mnp: req.mnp
? {
reserveNumber: req.mnp.reserveNumber || "",
reserveExpireDate: req.mnp.reserveExpireDate || "",
}
: undefined,
});
} else {
this.logger.warn("Physical SIM activation path is not implemented; skipping Freebit call", {
account: req.msisdn,
});
}
billingResult = await this.simBilling.createOneTimeCharge({
clientId: mapping.whmcsClientId,
description: `SIM Activation Fee (${req.planSku}) for ${req.msisdn}`,
amountJpy: req.oneTimeAmountJpy,
currency: "JPY",
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
notes: `SIM activation fee for ${req.msisdn}, plan ${req.planSku}. Monthly billing will start on the 1st of next month.`,
failureNotesPrefix: "SIM activation payment failed",
publicErrorMessage: "Unable to process SIM activation payment. Please update your payment method.",
metadata: { userId, msisdn: req.msisdn },
});
await this.freebit.activateEsimAccountNew({
account: req.msisdn,
eid: req.eid!,
planCode: req.planSku,
contractLine: "5G",
shipDate: req.activationType === "Scheduled" ? req.scheduledAt : undefined,
mnp: req.mnp
? {
reserveNumber: req.mnp.reserveNumber || "",
reserveExpireDate: req.mnp.reserveExpireDate || "",
}
: undefined,
});
// 3) Add-ons (voice options) immediately after activation if selected
if (req.addons && (req.addons.voiceMail || req.addons.callWaiting)) {
await this.freebit.updateSimFeatures(req.msisdn, {
voiceMailEnabled: !!req.addons.voiceMail,
@ -121,25 +138,19 @@ export class SimOrderActivationService {
});
}
// 4) Create monthly subscription for recurring billing
if (req.monthlyAmountJpy > 0) {
const nextMonth = new Date();
nextMonth.setMonth(nextMonth.getMonth() + 1);
nextMonth.setDate(1); // First day of next month
nextMonth.setHours(0, 0, 0, 0);
// Create a monthly subscription order using the order service
const nextBillingIso = this.simSchedule.firstDayOfNextMonthIsoDate();
const orderService = this.whmcs.getOrderService();
await orderService.addOrder({
clientId: mapping.whmcsClientId,
items: [
{
productId: req.planSku, // Use the plan SKU as product ID
productId: req.planSku,
billingCycle: "monthly",
quantity: 1,
configOptions: {
phone_number: req.msisdn,
activation_date: nextMonth.toISOString().split("T")[0],
activation_date: nextBillingIso,
},
customFields: {
sim_type: req.simType,
@ -147,43 +158,42 @@ export class SimOrderActivationService {
},
},
],
paymentMethod: "mailin", // Default payment method
notes: `Monthly SIM plan billing for ${req.msisdn}, plan ${req.planSku}. Billing starts on the 1st of next month.`,
noinvoice: false, // Create invoice
noinvoiceemail: true, // Suppress invoice email for now
noemail: true, // Suppress order emails
paymentMethod: "mailin",
notes: `Monthly SIM plan billing for ${req.msisdn}, plan ${req.planSku}. Billing starts on ${nextBillingIso}.`,
noinvoice: false,
noinvoiceemail: true,
noemail: true,
});
this.logger.log("Monthly subscription created", {
account: req.msisdn,
amount: req.monthlyAmountJpy,
nextDueDate: nextMonth.toISOString().split("T")[0],
nextDueDate: nextBillingIso,
});
}
this.logger.log("SIM activation completed", { account: req.msisdn, invoiceId: invoice.id });
this.logger.log("SIM activation completed", {
account: req.msisdn,
invoiceId: billingResult.invoice.id,
});
const result = {
success: true,
invoiceId: invoice.id,
transactionId: paymentResult.transactionId,
invoiceId: billingResult.invoice.id,
transactionId: billingResult.transactionId,
};
// Cache successful result for 24 hours
await this.cache.set(cacheKey, result, 86400);
// Remove processing flag
await this.cache.del(processingKey);
await releaseProcessingFlag();
return result;
} catch (err) {
// Remove processing flag on error
await this.cache.del(processingKey);
await this.whmcs.updateInvoice({
invoiceId: invoice.id,
notes: `Freebit activation failed after payment: ${getErrorMessage(err)}`,
});
await releaseProcessingFlag();
if (billingResult?.invoice) {
await this.simBilling.appendInvoiceNote(
billingResult.invoice.id,
`Freebit activation failed after payment: ${getErrorMessage(err)}`
);
}
throw err;
}
}

View File

@ -136,7 +136,7 @@ const NavigationItem = memo(function NavigationItem({
const isChildActive = pathname === (child.href || "").split(/[?#]/)[0];
return (
<Link
key={child.name}
key={child.href || child.name}
href={child.href}
prefetch
onMouseEnter={() => {

View File

@ -1,100 +1,129 @@
"use client";
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
interface ActivationFeeDetails {
amount: number;
name: string;
}
interface ActivationFormProps {
activationType: "Immediate" | "Scheduled";
onActivationTypeChange: (type: "Immediate" | "Scheduled") => void;
scheduledActivationDate: string;
onScheduledActivationDateChange: (date: string) => void;
errors: Record<string, string | undefined>;
activationFee?: ActivationFeeDetails;
}
type ActivationOption = {
type: "Immediate" | "Scheduled";
title: string;
description: string;
};
const activationOptions: ActivationOption[] = [
{
type: "Immediate",
title: "Immediate Activation",
description: "Activate as soon as your SIM arrives and is set up.",
},
{
type: "Scheduled",
title: "Scheduled Activation",
description: "Pick a go-live date within the next 30 days.",
},
];
export function ActivationForm({
activationType,
onActivationTypeChange,
scheduledActivationDate,
onScheduledActivationDateChange,
errors,
activationFee,
}: ActivationFormProps) {
const sharedLabelClasses =
"flex items-start gap-3 p-4 rounded-lg border-2 cursor-pointer transition-colors duration-200 ease-in-out";
return (
<div className="space-y-4">
<label
className={`${sharedLabelClasses} ${
activationType === "Immediate"
? "border-blue-500 bg-blue-50 shadow-sm ring-1 ring-blue-200"
: "border-gray-200 hover:border-blue-300 hover:bg-blue-50"
}`}
>
<input
type="radio"
name="activationType"
value="Immediate"
checked={activationType === "Immediate"}
onChange={e => onActivationTypeChange(e.target.value as "Immediate")}
className="mt-1 h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500"
/>
<div>
<span className="font-medium text-gray-900">Immediate Activation</span>
<p className="text-sm text-gray-600 mt-1">
Activate your SIM card as soon as it&apos;s delivered and set up
</p>
</div>
</label>
<label
className={`${sharedLabelClasses} ${
activationType === "Scheduled"
? "border-blue-500 bg-blue-50 shadow-sm ring-1 ring-blue-200"
: "border-gray-200 hover:border-blue-300 hover:bg-blue-50"
}`}
>
<input
type="radio"
name="activationType"
value="Scheduled"
checked={activationType === "Scheduled"}
onChange={e => onActivationTypeChange(e.target.value as "Scheduled")}
className="mt-1 h-4 w-4 text-blue-600 border-gray-300 focus:ring-blue-500"
/>
<div className="flex-1">
<span className="font-medium text-gray-900">Scheduled Activation</span>
<p className="text-sm text-gray-600 mt-1">
Choose a specific date for activation (up to 30 days from today)
</p>
<div
className={`overflow-hidden transition-[max-height,opacity] duration-300 ease-out ${
activationType === "Scheduled" ? "max-h-[240px] opacity-100" : "max-h-0 opacity-0"
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{activationOptions.map(option => {
const isSelected = activationType === option.type;
return (
<label
key={option.type}
className={`p-6 rounded-xl border-2 text-left transition-all duration-200 focus-within:ring-2 focus-within:ring-blue-500 focus-within:ring-offset-2 cursor-pointer flex flex-col gap-3 ${
isSelected
? "border-blue-500 bg-blue-50 shadow-md"
: "border-gray-200 hover:border-blue-400 hover:bg-blue-50/50 shadow-sm hover:shadow-md"
}`}
aria-hidden={activationType !== "Scheduled"}
>
<div className="mt-3">
<label
htmlFor="scheduledActivationDate"
className="block text-sm font-medium text-gray-700 mb-1"
<input
type="radio"
name="activationType"
value={option.type}
checked={isSelected}
onChange={e => onActivationTypeChange(e.target.value as "Immediate" | "Scheduled")}
className="sr-only"
/>
<div className="flex items-start justify-between gap-3">
<div className="flex-1 min-w-0">
<h4 className="text-lg font-semibold text-gray-900 leading-tight">{option.title}</h4>
</div>
<div
className={`w-5 h-5 rounded-full border-2 flex items-center justify-center flex-shrink-0 ${
isSelected ? "bg-blue-500 border-blue-500" : "border-gray-300 bg-white"
}`}
aria-hidden="true"
>
Preferred Activation Date *
</label>
<input
type="date"
id="scheduledActivationDate"
value={scheduledActivationDate}
onChange={e => onScheduledActivationDateChange(e.target.value)}
min={new Date().toISOString().split("T")[0]} // Today's date
max={new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]} // 30 days from now
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
/>
{errors.scheduledActivationDate && (
<p className="text-red-600 text-sm mt-1">{errors.scheduledActivationDate}</p>
)}
<p className="text-xs text-blue-700 mt-1">
Note: Scheduled activation is subject to business day processing. Weekend/holiday
requests may be processed on the next business day.
</p>
{isSelected && <div className="w-2 h-2 bg-white rounded-full" />}
</div>
</div>
</div>
</div>
</label>
<p className="text-sm text-gray-600">{option.description}</p>
{activationFee ? (
<div className="pt-3 border-t border-gray-200">
<CardPricing alignment="left" size="md" oneTimePrice={activationFee.amount} />
</div>
) : (
<p className="text-sm text-gray-500">Activation fee shown at checkout</p>
)}
{option.type === "Scheduled" && (
<div
className={`overflow-hidden transition-[max-height,opacity] duration-300 ease-out ${
isSelected ? "max-h-[260px] opacity-100" : "max-h-0 opacity-0"
}`}
aria-hidden={!isSelected}
>
<div className="mt-3">
<label
htmlFor="scheduledActivationDate"
className="block text-sm font-medium text-gray-700 mb-1"
>
Preferred activation date *
</label>
<input
type="date"
id="scheduledActivationDate"
value={scheduledActivationDate}
onChange={e => onScheduledActivationDateChange(e.target.value)}
min={new Date().toISOString().split("T")[0]}
max={new Date(Date.now() + 30 * 24 * 60 * 60 * 1000).toISOString().split("T")[0]}
className="w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-blue-500 bg-white"
/>
{errors.scheduledActivationDate && (
<p className="text-red-600 text-sm mt-1">{errors.scheduledActivationDate}</p>
)}
<p className="text-xs text-blue-700 mt-1">
Weekend or holiday requests may be processed on the next business day.
</p>
</div>
</div>
)}
</label>
);
})}
</div>
);
}

View File

@ -18,6 +18,7 @@ import {
UsersIcon,
} from "@heroicons/react/24/outline";
import type { UseSimConfigureResult } from "@/features/catalog/hooks/useSimConfigure";
import type { SimActivationFeeCatalogItem } from "@customer-portal/domain/catalog";
type Props = UseSimConfigureResult & {
onConfirm: () => void;
@ -47,6 +48,38 @@ export function SimConfigureView({
setCurrentStep,
onConfirm,
}: Props) {
const getRequiredActivationFee = (
fees: SimActivationFeeCatalogItem[]
): SimActivationFeeCatalogItem | undefined => {
if (!Array.isArray(fees) || fees.length === 0) {
return undefined;
}
return (
fees.find(fee => fee.catalogMetadata?.autoAdd) ||
fees.find(fee => fee.catalogMetadata?.isDefault) ||
fees[0]
);
};
const resolveOneTimeCharge = (value?: {
oneTimePrice?: number;
unitPrice?: number;
monthlyPrice?: number;
}): number => {
if (!value) return 0;
return value.oneTimePrice ?? value.unitPrice ?? value.monthlyPrice ?? 0;
};
const requiredActivationFee = getRequiredActivationFee(activationFees);
const activationFeeAmount = resolveOneTimeCharge(requiredActivationFee);
const activationFeeDetails =
requiredActivationFee && activationFeeAmount > 0
? {
name: requiredActivationFee.name,
amount: activationFeeAmount,
}
: undefined;
// Calculate display totals from catalog prices (for display only)
// Note: BFF will recalculate authoritative pricing
const monthlyTotal =
@ -58,6 +91,7 @@ export function SimConfigureView({
const oneTimeTotal =
(plan?.oneTimePrice ?? 0) +
activationFeeAmount +
selectedAddons.reduce((sum, addonSku) => {
const addon = addons.find(a => a.sku === addonSku);
return sum + (addon?.oneTimePrice ?? 0);
@ -262,6 +296,7 @@ export function SimConfigureView({
scheduledActivationDate={scheduledActivationDate}
onScheduledActivationDateChange={setScheduledActivationDate}
errors={{}}
activationFee={activationFeeDetails}
/>
<div className="flex justify-between mt-6">
<Button
@ -467,24 +502,25 @@ export function SimConfigureView({
</div>
)}
{activationFees.length > 0 &&
activationFees.some(fee => (fee.oneTimePrice ?? fee.unitPrice ?? 0) > 0) && (
<div className="border-t border-gray-200 pt-4 mb-6">
<h4 className="font-medium text-gray-900 mb-3">One-time Fees</h4>
<div className="space-y-2">
{activationFees.map((fee, index) => {
const feeAmount =
fee.oneTimePrice ?? fee.unitPrice ?? fee.monthlyPrice ?? 0;
return (
<div key={index} className="flex justify-between text-sm">
<span className="text-gray-600">{fee.name}</span>
<span className="text-gray-900">¥{feeAmount.toLocaleString()}</span>
</div>
);
})}
{activationFeeDetails && (
<div className="border-t border-gray-200 pt-4 mb-6">
<h4 className="font-medium text-gray-900 mb-3">One-time Fees</h4>
<div className="space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">{activationFeeDetails.name}</span>
<span className="text-gray-900">
¥{activationFeeDetails.amount.toLocaleString()}
</span>
</div>
{(requiredActivationFee?.catalogMetadata?.autoAdd ||
requiredActivationFee?.catalogMetadata?.isDefault) && (
<p className="text-xs text-gray-500">
Required for all new SIM activations
</p>
)}
</div>
)}
</div>
)}
<div className="border-t-2 border-dashed border-gray-300 pt-4 bg-gray-50 -mx-6 px-6 py-4 rounded-b-lg">
<div className="space-y-2">

View File

@ -15,9 +15,14 @@ import {
} from "@customer-portal/domain/toolkit";
import type { AsyncState } from "@customer-portal/domain/toolkit";
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
import { ORDER_TYPE, type CheckoutCart } from "@customer-portal/domain/orders";
import {
ORDER_TYPE,
orderWithSkuValidationSchema,
type CheckoutCart,
} from "@customer-portal/domain/orders";
import { CheckoutParamsService } from "@/features/checkout/services/checkout-params.service";
import { useAuthSession } from "@/features/auth/services/auth.store";
import { useAuthSession, useAuthStore } from "@/features/auth/services/auth.store";
import { ZodError } from "zod";
// Use domain Address type
import type { Address } from "@customer-portal/domain/customer";
@ -141,6 +146,11 @@ export function useCheckout() {
}
const cart = checkoutState.data;
// Debug logging to check cart contents
console.log("[DEBUG] Cart data:", cart);
console.log("[DEBUG] Cart items:", cart.items);
const uniqueSkus = Array.from(
new Set(
cart.items
@ -149,6 +159,8 @@ export function useCheckout() {
)
);
console.log("[DEBUG] Extracted SKUs from cart:", uniqueSkus);
if (uniqueSkus.length === 0) {
throw new Error("No products selected for order. Please go back and select products.");
}
@ -164,6 +176,22 @@ export function useCheckout() {
: {}),
};
const currentUserId = useAuthStore.getState().user?.id;
if (currentUserId) {
try {
orderWithSkuValidationSchema.parse({
...orderData,
userId: currentUserId,
});
} catch (validationError) {
if (validationError instanceof ZodError) {
const firstIssue = validationError.issues.at(0);
throw new Error(firstIssue?.message || "Order contains invalid data");
}
throw validationError;
}
}
const response = await ordersService.createOrder(orderData);
router.push(`/orders/${response.sfOrderId}?status=success`);
} catch (error) {

View File

@ -14,11 +14,29 @@ async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: st
skus: payload.skus,
...(payload.configurations ? { configurations: payload.configurations } : {}),
};
const response = await apiClient.POST("/api/orders", { body });
const parsed = assertSuccess<{ sfOrderId: string; status: string; message: string }>(
response.data as DomainApiResponse<{ sfOrderId: string; status: string; message: string }>
);
return { sfOrderId: parsed.data.sfOrderId };
// Debug logging
console.log("[DEBUG] Creating order with payload:", JSON.stringify(body, null, 2));
console.log("[DEBUG] Order Type:", body.orderType);
console.log("[DEBUG] SKUs:", body.skus);
console.log("[DEBUG] Configurations:", body.configurations);
try {
const response = await apiClient.POST("/api/orders", { body });
console.log("[DEBUG] Response:", response);
const parsed = assertSuccess<{ sfOrderId: string; status: string; message: string }>(
response.data as DomainApiResponse<{ sfOrderId: string; status: string; message: string }>
);
return { sfOrderId: parsed.data.sfOrderId };
} catch (error) {
console.error("[DEBUG] Order creation failed:", error);
if (error && typeof error === "object" && "body" in error) {
console.error("[DEBUG] Error body:", error.body);
}
throw error;
}
}
async function getMyOrders(): Promise<OrderSummary[]> {

81
docs/sim/STATE_MACHINE.md Normal file
View File

@ -0,0 +1,81 @@
# SIM Order & Lifecycle States
This document stays in sync with the strongly typed lifecycle definitions that live in the SIM
domain package. When workflow steps change, update the types first and then revise this doc so
deployments, QA, and onboarding all use the same vocabulary.
## Core Types
The lifecycle exports below are the single source of truth for state names and transitions.
```1:120:packages/domain/sim/lifecycle.ts
export const SIM_LIFECYCLE_STAGE = {
CHECKOUT: "checkout",
ORDER_PENDING_REVIEW: "order.pendingReview",
ACTIVATION_PROCESSING: "activation.processing",
ACTIVATION_FAILED_PAYMENT: "activation.failedPayment",
ACTIVATION_PROVISIONING: "activation.provisioning",
SERVICE_ACTIVE: "service.active",
PLAN_CHANGE_SCHEDULED: "planChange.scheduled",
PLAN_CHANGE_APPLIED: "planChange.applied",
CANCELLATION_SCHEDULED: "cancellation.scheduled",
SERVICE_CANCELLED: "service.cancelled",
} as const;
export const SIM_MANAGEMENT_ACTION = {
TOP_UP_DATA: "topUpData",
CHANGE_PLAN: "changePlan",
UPDATE_FEATURES: "updateFeatures",
CANCEL_SIM: "cancelSim",
REISSUE_ESIM: "reissueEsim",
} as const;
```
- `SimLifecycleStage` — union of the keys above.
- `SimManagementAction` — canonical action names referenced by BFF services, workers, and docs.
- `SIM_ORDER_FLOW` — ordered list of activation transitions.
- `SIM_MANAGEMENT_FLOW` — per-action transitions that describe how a request moves between states.
## Order + Activation Flow (`SIM_ORDER_FLOW`)
| Step | Transition | Details |
|------|------------|---------|
| 1 | `checkout.createOrder`: `checkout → order.pendingReview` | `orderWithSkuValidationSchema` ensures the cart is valid before the BFF writes a Salesforce order. |
| 2 | `orders.activateSim`: `order.pendingReview → activation.processing` | `SimOrderActivationService` locks the request (cache key) and kicks off billing. |
| 3a | `payments.failure`: `activation.processing → activation.failedPayment` | WHMCS capture failed. Invoice is cancelled via `SimBillingService`, user must retry. |
| 3b | `payments.success`: `activation.processing → activation.provisioning` | Payment succeeded; Freebit provisioning and add-on updates run. |
| 4 | `provisioning.complete`: `activation.provisioning → service.active` | Freebit returns success, cache records the idempotent result, and the monthly WHMCS subscription is scheduled for the first of next month via `SimScheduleService`. |
## SIM Management Actions (`SIM_MANAGEMENT_FLOW`)
All customer-facing actions execute through `SimActionRunnerService`, guaranteeing consistent
notifications and structured logs. The table below maps directly to the typed transitions.
| Action (`SimManagementAction`) | Transition(s) | Notes |
|--------------------------------|---------------|-------|
| `topUpData` | `service.active → service.active` | One-time invoice captured through `SimBillingService`; Freebit quota increases immediately. |
| `changePlan` | `service.active → planChange.scheduled → planChange.applied → service.active` | `SimScheduleService.resolveScheduledDate` auto-picks the first day of next month unless the user provides `scheduledAt`. |
| `updateFeatures` | `service.active → service.active` | Voice toggles run instantly. If `networkType` changes, the second phase is queued (see below). |
| `cancelSim` | `service.active → cancellation.scheduled → service.cancelled` | Default schedule = first day of next month; users can override by passing a valid `YYYYMMDD` date. |
| `reissueEsim` | `service.active → service.active` | Freebit eSIM profile reissued; lifecycle stage does not change. |
## Deferred Jobs
Network-type changes are persisted as BullMQ jobs so they survive restarts and include retry
telemetry.
- Enqueue: `SimPlanService``SimManagementQueueService.scheduleNetworkTypeChange`
- Worker: `SimManagementProcessor` consumes the `sim-management` queue and calls
`FreebitOrchestratorService.updateSimFeatures` when the delay expires.
## Validation & Feedback Layers
| Layer | Enforcement | Customer feedback |
|-------|-------------|-------------------|
| Frontend | Domain schemas (`orderWithSkuValidationSchema`, SIM configure schemas) | Immediate form errors (e.g., missing activation fee, invalid EID). |
| BFF | `SimOrderActivationService`, `SimPlanService`, `SimTopUpService` | Sanitized business errors (`VAL_001`, payment failures) routed through Secure Error Mapper. |
| Background jobs | `SimManagementProcessor` | Logged with request metadata; failures fan out via `SimNotificationService`. |
Keep this file and `packages/domain/sim/lifecycle.ts` in lockstep. When adding a new stage or action,
update the domain types first, then describe the change here so every team shares the same model.

View File

@ -187,11 +187,15 @@ export function mapSimActivationFee(
pricebookEntry?: SalesforcePricebookEntryRecord
): SimActivationFeeCatalogItem {
const simProduct = mapSimProduct(product, pricebookEntry);
const isDefault = product.Is_Default__c === true;
const autoAdd = product.Auto_Add__c === true;
return {
...simProduct,
catalogMetadata: {
isDefault: true,
...(simProduct.catalogMetadata ?? {}),
isDefault,
autoAdd,
},
};
}

View File

@ -21,6 +21,9 @@ export const salesforceProduct2RecordSchema = z.object({
Item_Class__c: z.string().nullable().optional(),
Billing_Cycle__c: z.string().nullable().optional(),
Catalog_Order__c: z.number().nullable().optional(),
Display_Order__c: z.number().nullable().optional(),
Auto_Add__c: z.boolean().nullable().optional(),
Is_Default__c: z.boolean().nullable().optional(),
Bundled_Addon__c: z.string().nullable().optional(),
Is_Bundled_Addon__c: z.boolean().nullable().optional(),
Internet_Plan_Tier__c: z.string().nullable().optional(),

View File

@ -92,9 +92,12 @@ export const simCatalogProductSchema = catalogProductBaseSchema.extend({
});
export const simActivationFeeCatalogItemSchema = simCatalogProductSchema.extend({
catalogMetadata: z.object({
isDefault: z.boolean(),
}).optional(),
catalogMetadata: z
.object({
isDefault: z.boolean().optional(),
autoAdd: z.boolean().optional(),
})
.optional(),
});
export const simCatalogCollectionSchema = z.object({

View File

@ -28,12 +28,9 @@ export function hasSimServicePlan(skus: string[]): boolean {
/**
* Check if SKUs array contains a SIM activation fee
*/
export function hasSimActivationFee(skus: string[]): boolean {
return skus.some(
(sku) =>
sku.toUpperCase().includes("ACTIVATION") ||
sku.toUpperCase().includes("SIM-ACTIVATION")
);
export function hasSimActivationFee(_skus: string[]): boolean {
// Deprecated: rely on catalog metadata instead of heuristics.
return true;
}
/**
@ -100,13 +97,6 @@ export const orderWithSkuValidationSchema = orderBusinessValidationSchema
path: ["skus"],
}
)
.refine(
(data) => data.orderType !== "SIM" || hasSimActivationFee(data.skus),
{
message: "SIM orders require an activation fee",
path: ["skus"],
}
)
.refine(
(data) => data.orderType !== "VPN" || hasVpnActivationFee(data.skus),
{
@ -137,11 +127,7 @@ export function getOrderTypeValidationError(orderType: string, skus: string[]):
if (!hasSimServicePlan(skus)) {
return "A SIM plan must be selected";
}
if (!hasSimActivationFee(skus)) {
return "SIM orders require an activation fee";
}
break;
case "VPN":
if (!hasVpnActivationFee(skus)) {
return "VPN orders require an activation fee";

View File

@ -11,6 +11,7 @@ export { SIM_STATUS, SIM_TYPE } from "./contract";
// Schemas (includes derived types)
export * from "./schema";
export * from "./lifecycle";
// Validation functions
export * from "./validation";

View File

@ -0,0 +1,141 @@
/**
* SIM Lifecycle Types
*
* Shared state definitions keep BFF workflow docs, frontend logic, and tests
* aligned. Docs reference these exports instead of duplicating string literals.
*/
export const SIM_LIFECYCLE_STAGE = {
CHECKOUT: "checkout",
ORDER_PENDING_REVIEW: "order.pendingReview",
ACTIVATION_PROCESSING: "activation.processing",
ACTIVATION_FAILED_PAYMENT: "activation.failedPayment",
ACTIVATION_PROVISIONING: "activation.provisioning",
SERVICE_ACTIVE: "service.active",
PLAN_CHANGE_SCHEDULED: "planChange.scheduled",
PLAN_CHANGE_APPLIED: "planChange.applied",
CANCELLATION_SCHEDULED: "cancellation.scheduled",
SERVICE_CANCELLED: "service.cancelled",
} as const;
export type SimLifecycleStage = (typeof SIM_LIFECYCLE_STAGE)[keyof typeof SIM_LIFECYCLE_STAGE];
export const SIM_MANAGEMENT_ACTION = {
TOP_UP_DATA: "topUpData",
CHANGE_PLAN: "changePlan",
UPDATE_FEATURES: "updateFeatures",
CANCEL_SIM: "cancelSim",
REISSUE_ESIM: "reissueEsim",
} as const;
export type SimManagementAction =
(typeof SIM_MANAGEMENT_ACTION)[keyof typeof SIM_MANAGEMENT_ACTION];
export interface SimLifecycleTransition {
action: string;
from: SimLifecycleStage;
to: SimLifecycleStage;
description: string;
blocking?: boolean;
}
export const SIM_ORDER_FLOW: SimLifecycleTransition[] = [
{
action: "checkout.createOrder",
from: SIM_LIFECYCLE_STAGE.CHECKOUT,
to: SIM_LIFECYCLE_STAGE.ORDER_PENDING_REVIEW,
description: "Customer submits validated cart; Salesforce order enters Pending Review.",
blocking: true,
},
{
action: "orders.activateSim",
from: SIM_LIFECYCLE_STAGE.ORDER_PENDING_REVIEW,
to: SIM_LIFECYCLE_STAGE.ACTIVATION_PROCESSING,
description: "Activation workflow begins (invoice + idempotent cache lock).",
blocking: true,
},
{
action: "payments.failure",
from: SIM_LIFECYCLE_STAGE.ACTIVATION_PROCESSING,
to: SIM_LIFECYCLE_STAGE.ACTIVATION_FAILED_PAYMENT,
description: "WHMCS payment capture failed; invoice cancelled and user prompted to retry.",
blocking: true,
},
{
action: "payments.success",
from: SIM_LIFECYCLE_STAGE.ACTIVATION_PROCESSING,
to: SIM_LIFECYCLE_STAGE.ACTIVATION_PROVISIONING,
description: "Payment succeeded; Freebit provisioning + add-ons run.",
},
{
action: "provisioning.complete",
from: SIM_LIFECYCLE_STAGE.ACTIVATION_PROVISIONING,
to: SIM_LIFECYCLE_STAGE.SERVICE_ACTIVE,
description: "Freebit activation finished; monthly WHMCS order scheduled for next month.",
},
];
type ManagementFlow = Record<SimManagementAction, SimLifecycleTransition[]>;
export const SIM_MANAGEMENT_FLOW: ManagementFlow = {
[SIM_MANAGEMENT_ACTION.TOP_UP_DATA]: [
{
action: SIM_MANAGEMENT_ACTION.TOP_UP_DATA,
from: SIM_LIFECYCLE_STAGE.SERVICE_ACTIVE,
to: SIM_LIFECYCLE_STAGE.SERVICE_ACTIVE,
description: "One-time charge + Freebit quota increase; no state change.",
},
],
[SIM_MANAGEMENT_ACTION.CHANGE_PLAN]: [
{
action: SIM_MANAGEMENT_ACTION.CHANGE_PLAN,
from: SIM_LIFECYCLE_STAGE.SERVICE_ACTIVE,
to: SIM_LIFECYCLE_STAGE.PLAN_CHANGE_SCHEDULED,
description: "Freebit accepts plan change; SimScheduleService decides run date.",
},
{
action: "planChange.applied",
from: SIM_LIFECYCLE_STAGE.PLAN_CHANGE_SCHEDULED,
to: SIM_LIFECYCLE_STAGE.PLAN_CHANGE_APPLIED,
description: "Scheduled Freebit job applied (typically first day of next month).",
},
{
action: "planChange.returnToActive",
from: SIM_LIFECYCLE_STAGE.PLAN_CHANGE_APPLIED,
to: SIM_LIFECYCLE_STAGE.SERVICE_ACTIVE,
description: "Service returns to active steady state with new plan code.",
},
],
[SIM_MANAGEMENT_ACTION.UPDATE_FEATURES]: [
{
action: SIM_MANAGEMENT_ACTION.UPDATE_FEATURES,
from: SIM_LIFECYCLE_STAGE.SERVICE_ACTIVE,
to: SIM_LIFECYCLE_STAGE.SERVICE_ACTIVE,
description:
"Immediate feature toggles. If networkType changes, a deferred queue job runs 30 minutes later.",
},
],
[SIM_MANAGEMENT_ACTION.CANCEL_SIM]: [
{
action: SIM_MANAGEMENT_ACTION.CANCEL_SIM,
from: SIM_LIFECYCLE_STAGE.SERVICE_ACTIVE,
to: SIM_LIFECYCLE_STAGE.CANCELLATION_SCHEDULED,
description: "Cancellation accepted; SimScheduleService defaults to first day of next month.",
},
{
action: "cancellation.complete",
from: SIM_LIFECYCLE_STAGE.CANCELLATION_SCHEDULED,
to: SIM_LIFECYCLE_STAGE.SERVICE_CANCELLED,
description: "Freebit executes scheduled cancellation; WHMCS/Freebit status synced.",
},
],
[SIM_MANAGEMENT_ACTION.REISSUE_ESIM]: [
{
action: SIM_MANAGEMENT_ACTION.REISSUE_ESIM,
from: SIM_LIFECYCLE_STAGE.SERVICE_ACTIVE,
to: SIM_LIFECYCLE_STAGE.SERVICE_ACTIVE,
description: "eSIM profile reissued; lifecycle state does not change.",
},
],
};