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:
parent
fc2c46b21e
commit
c741ece844
@ -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];
|
||||
|
||||
@ -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],
|
||||
|
||||
@ -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 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 => ({
|
||||
|
||||
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@ -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,15 +256,31 @@ 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 })),
|
||||
});
|
||||
|
||||
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,
|
||||
});
|
||||
|
||||
if (activation) {
|
||||
items.push({
|
||||
id: activation.id,
|
||||
sku: activation.sku,
|
||||
@ -268,8 +290,8 @@ export class CheckoutService {
|
||||
oneTimePrice: activation.oneTimePrice,
|
||||
quantity: 1,
|
||||
itemType: "activation",
|
||||
autoAdded,
|
||||
});
|
||||
}
|
||||
|
||||
// Add addons
|
||||
const addonRefs = this.collectAddonRefs(selections);
|
||||
@ -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
|
||||
*/
|
||||
|
||||
@ -13,7 +13,7 @@ import {
|
||||
sanitizeSoqlLiteral,
|
||||
} from "@bff/integrations/salesforce/utils/soql.util";
|
||||
|
||||
interface PricebookProductMeta {
|
||||
export interface PricebookProductMeta {
|
||||
sku: string;
|
||||
pricebookEntryId: string;
|
||||
product2Id?: string;
|
||||
|
||||
@ -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") {
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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");
|
||||
}
|
||||
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.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;
|
||||
|
||||
await this.freebitService.cancelSim(account, runDate);
|
||||
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,
|
||||
runDate: scheduleResolution.date,
|
||||
});
|
||||
|
||||
await this.simNotification.notifySimAction("Cancel SIM", "SUCCESS", {
|
||||
userId,
|
||||
subscriptionId,
|
||||
return {
|
||||
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;
|
||||
}
|
||||
runDate: scheduleResolution.date,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,50 +27,49 @@ 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");
|
||||
}
|
||||
} 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,
|
||||
scheduledAt: scheduleResolution.date,
|
||||
assignGlobalIp,
|
||||
scheduleOrigin: request.scheduledAt ? "user-provided" : "auto-default",
|
||||
scheduleOrigin: scheduleResolution.source,
|
||||
});
|
||||
|
||||
if (!scheduledAt) {
|
||||
throw new BadRequestException("Failed to determine schedule date for plan change");
|
||||
}
|
||||
|
||||
const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, {
|
||||
assignGlobalIp,
|
||||
scheduledAt,
|
||||
scheduledAt: scheduleResolution.date,
|
||||
});
|
||||
|
||||
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
|
||||
@ -74,40 +77,22 @@ export class SimPlanService {
|
||||
subscriptionId,
|
||||
account,
|
||||
newPlanCode: request.newPlanCode,
|
||||
scheduledAt,
|
||||
scheduledAt: scheduleResolution.date,
|
||||
assignGlobalIp,
|
||||
});
|
||||
|
||||
await this.simNotification.notifySimAction("Change Plan", "SUCCESS", {
|
||||
userId,
|
||||
subscriptionId,
|
||||
return {
|
||||
...result,
|
||||
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;
|
||||
scheduledAt: scheduleResolution.date,
|
||||
};
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
ipv4: response.ipv4,
|
||||
ipv6: response.ipv6,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@ -118,10 +103,27 @@ export class SimPlanService {
|
||||
subscriptionId: number,
|
||||
request: SimFeaturesUpdateRequest
|
||||
): Promise<void> {
|
||||
try {
|
||||
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||
let account = "";
|
||||
|
||||
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;
|
||||
|
||||
// Validate network type if provided
|
||||
if (request.networkType && !["4G", "5G"].includes(request.networkType)) {
|
||||
throw new BadRequestException('networkType must be either "4G" or "5G"');
|
||||
}
|
||||
@ -133,37 +135,20 @@ export class SimPlanService {
|
||||
const doContract = typeof request.networkType === "string";
|
||||
|
||||
if (doVoice && doContract) {
|
||||
// First apply voice options immediately (PA05-06)
|
||||
await this.freebitService.updateSimFeatures(account, {
|
||||
voiceMailEnabled: request.voiceMailEnabled,
|
||||
callWaitingEnabled: request.callWaitingEnabled,
|
||||
internationalRoamingEnabled: request.internationalRoamingEnabled,
|
||||
});
|
||||
|
||||
// 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", {
|
||||
await this.simQueue.scheduleNetworkTypeChange({
|
||||
account,
|
||||
networkType: request.networkType as "4G" | "5G",
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
networkType: request.networkType,
|
||||
})
|
||||
)
|
||||
.catch(err =>
|
||||
this.logger.error("Deferred contract line change failed", {
|
||||
error: getErrorMessage(err),
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
})
|
||||
);
|
||||
}, delayMs);
|
||||
});
|
||||
|
||||
this.logger.log("Scheduled contract line change 30 minutes after voice option change", {
|
||||
this.logger.log("Scheduled contract line change via queue after voice option change", {
|
||||
userId,
|
||||
subscriptionId,
|
||||
account,
|
||||
@ -179,32 +164,7 @@ export class SimPlanService {
|
||||
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;
|
||||
}
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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];
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,172 +24,103 @@ export class SimTopUpService {
|
||||
* Pricing: 1GB = 500 JPY
|
||||
*/
|
||||
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
||||
let account: string = "";
|
||||
let latestAccount = "";
|
||||
|
||||
try {
|
||||
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);
|
||||
account = validation.account;
|
||||
latestAccount = 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,
|
||||
account: latestAccount,
|
||||
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,
|
||||
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), // 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,
|
||||
subscriptionId,
|
||||
});
|
||||
|
||||
// Cancel the invoice since payment failed
|
||||
await this.handlePaymentFailure(invoice.id, paymentResult.error || "Unknown payment error");
|
||||
|
||||
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,
|
||||
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 },
|
||||
});
|
||||
|
||||
try {
|
||||
// Step 3: Only if payment successful, add data via Freebit
|
||||
await this.freebitService.topUpSim(account, request.quotaMb, {});
|
||||
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,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle Freebit API failure after successful payment
|
||||
@ -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.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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}`, {
|
||||
|
||||
@ -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 {}
|
||||
|
||||
@ -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,49 +55,68 @@ 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({
|
||||
let billingResult:
|
||||
| {
|
||||
invoice: { id: number; number: string };
|
||||
transactionId?: string;
|
||||
}
|
||||
| undefined;
|
||||
|
||||
try {
|
||||
billingResult = await this.simBilling.createOneTimeCharge({
|
||||
clientId: mapping.whmcsClientId,
|
||||
description: `SIM Activation Fee (${req.planSku}) for ${req.msisdn}`,
|
||||
amount: req.oneTimeAmountJpy,
|
||||
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 },
|
||||
});
|
||||
|
||||
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!,
|
||||
@ -107,13 +130,7 @@ export class SimOrderActivationService {
|
||||
}
|
||||
: undefined,
|
||||
});
|
||||
} else {
|
||||
this.logger.warn("Physical SIM activation path is not implemented; skipping Freebit call", {
|
||||
account: req.msisdn,
|
||||
});
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
|
||||
@ -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={() => {
|
||||
|
||||
@ -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">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{activationOptions.map(option => {
|
||||
const isSelected = activationType === option.type;
|
||||
return (
|
||||
<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"
|
||||
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"
|
||||
}`}
|
||||
>
|
||||
<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"
|
||||
value={option.type}
|
||||
checked={isSelected}
|
||||
onChange={e => onActivationTypeChange(e.target.value as "Immediate" | "Scheduled")}
|
||||
className="sr-only"
|
||||
/>
|
||||
<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's delivered and set up
|
||||
</p>
|
||||
|
||||
<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>
|
||||
</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"
|
||||
<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"
|
||||
>
|
||||
<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>
|
||||
{isSelected && <div className="w-2 h-2 bg-white rounded-full" />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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 ${
|
||||
activationType === "Scheduled" ? "max-h-[240px] opacity-100" : "max-h-0 opacity-0"
|
||||
isSelected ? "max-h-[260px] opacity-100" : "max-h-0 opacity-0"
|
||||
}`}
|
||||
aria-hidden={activationType !== "Scheduled"}
|
||||
aria-hidden={!isSelected}
|
||||
>
|
||||
<div className="mt-3">
|
||||
<label
|
||||
htmlFor="scheduledActivationDate"
|
||||
className="block text-sm font-medium text-gray-700 mb-1"
|
||||
>
|
||||
Preferred Activation Date *
|
||||
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"
|
||||
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">
|
||||
Note: Scheduled activation is subject to business day processing. Weekend/holiday
|
||||
requests may be processed on the next business day.
|
||||
Weekend or holiday requests may be processed on the next business day.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -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,21 +502,22 @@ export function SimConfigureView({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activationFees.length > 0 &&
|
||||
activationFees.some(fee => (fee.oneTimePrice ?? fee.unitPrice ?? 0) > 0) && (
|
||||
{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">
|
||||
{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 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>
|
||||
)}
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -14,11 +14,29 @@ async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: st
|
||||
skus: payload.skus,
|
||||
...(payload.configurations ? { configurations: payload.configurations } : {}),
|
||||
};
|
||||
|
||||
// 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
81
docs/sim/STATE_MACHINE.md
Normal 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.
|
||||
|
||||
@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@ -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(),
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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";
|
||||
|
||||
@ -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";
|
||||
|
||||
141
packages/domain/sim/lifecycle.ts
Normal file
141
packages/domain/sim/lifecycle.ts
Normal 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.",
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user