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",
|
EMAIL: "email",
|
||||||
PROVISIONING: "provisioning",
|
PROVISIONING: "provisioning",
|
||||||
RECONCILE: "reconcile",
|
RECONCILE: "reconcile",
|
||||||
|
SIM_MANAGEMENT: "sim-management",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
export type QueueName = (typeof QUEUE_NAMES)[keyof typeof QUEUE_NAMES];
|
export type QueueName = (typeof QUEUE_NAMES)[keyof typeof QUEUE_NAMES];
|
||||||
|
|||||||
@ -33,7 +33,8 @@ function parseRedisConnection(redisUrl: string) {
|
|||||||
BullModule.registerQueue(
|
BullModule.registerQueue(
|
||||||
{ name: QUEUE_NAMES.EMAIL },
|
{ name: QUEUE_NAMES.EMAIL },
|
||||||
{ name: QUEUE_NAMES.PROVISIONING },
|
{ name: QUEUE_NAMES.PROVISIONING },
|
||||||
{ name: QUEUE_NAMES.RECONCILE }
|
{ name: QUEUE_NAMES.RECONCILE },
|
||||||
|
{ name: QUEUE_NAMES.SIM_MANAGEMENT }
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
exports: [BullModule],
|
exports: [BullModule],
|
||||||
|
|||||||
@ -67,16 +67,46 @@ export class SimCatalogService extends BaseCatalogService {
|
|||||||
return this.catalogCache.getCachedCatalog(
|
return this.catalogCache.getCachedCatalog(
|
||||||
cacheKey,
|
cacheKey,
|
||||||
async () => {
|
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>(
|
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||||
soql,
|
soql,
|
||||||
"SIM Activation Fees"
|
"SIM Activation Fees"
|
||||||
);
|
);
|
||||||
|
|
||||||
return records.map(record => {
|
const activationFees = records
|
||||||
const entry = this.extractPricebookEntry(record);
|
.map(record => {
|
||||||
return CatalogProviders.Salesforce.mapSimActivationFee(record, entry);
|
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 => ({
|
resolveDependencies: products => ({
|
||||||
|
|||||||
@ -10,6 +10,7 @@ import type { VpnCatalogService } from "@bff/modules/catalog/services/vpn-catalo
|
|||||||
const createLogger = (): Logger =>
|
const createLogger = (): Logger =>
|
||||||
({
|
({
|
||||||
log: jest.fn(),
|
log: jest.fn(),
|
||||||
|
warn: jest.fn(),
|
||||||
error: jest.fn(),
|
error: jest.fn(),
|
||||||
}) as unknown as Logger;
|
}) as unknown as Logger;
|
||||||
|
|
||||||
@ -22,6 +23,41 @@ const internetPlan = {
|
|||||||
oneTimePrice: 0,
|
oneTimePrice: 0,
|
||||||
} as unknown;
|
} 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 = ({
|
const createService = ({
|
||||||
internet,
|
internet,
|
||||||
sim,
|
sim,
|
||||||
@ -122,3 +158,128 @@ describe("CheckoutService - personalized carts", () => {
|
|||||||
expect(internetCatalogService.getPlansForUser).not.toHaveBeenCalled();
|
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", {
|
this.logger.log("Checkout cart built successfully", {
|
||||||
itemCount: validatedCart.items.length,
|
itemCount: validatedCart.items.length,
|
||||||
|
items: validatedCart.items.map(item => ({
|
||||||
|
sku: item.sku,
|
||||||
|
name: item.name,
|
||||||
|
itemType: item.itemType,
|
||||||
|
})),
|
||||||
monthlyTotal: validatedCart.totals.monthlyTotal,
|
monthlyTotal: validatedCart.totals.monthlyTotal,
|
||||||
oneTimeTotal: validatedCart.totals.oneTimeTotal,
|
oneTimeTotal: validatedCart.totals.oneTimeTotal,
|
||||||
});
|
});
|
||||||
@ -225,8 +230,9 @@ export class CheckoutService {
|
|||||||
const plans: SimCatalogProduct[] = userId
|
const plans: SimCatalogProduct[] = userId
|
||||||
? await this.simCatalogService.getPlansForUser(userId)
|
? await this.simCatalogService.getPlansForUser(userId)
|
||||||
: await this.simCatalogService.getPlans();
|
: await this.simCatalogService.getPlans();
|
||||||
const activationFees: SimActivationFeeCatalogItem[] =
|
const rawActivationFees: SimActivationFeeCatalogItem[] =
|
||||||
await this.simCatalogService.getActivationFees();
|
await this.simCatalogService.getActivationFees();
|
||||||
|
const activationFees = this.filterActivationFeesWithSku(rawActivationFees);
|
||||||
const addons: SimCatalogProduct[] = await this.simCatalogService.getAddons();
|
const addons: SimCatalogProduct[] = await this.simCatalogService.getAddons();
|
||||||
|
|
||||||
// Add main plan
|
// Add main plan
|
||||||
@ -250,27 +256,43 @@ export class CheckoutService {
|
|||||||
itemType: "plan",
|
itemType: "plan",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Add activation fee
|
// Add activation fee (required)
|
||||||
const simType = selections.simType || "eSIM";
|
this.logger.debug("Activation fees available", {
|
||||||
const activation = activationFees.find(fee => {
|
rawActivationFeeCount: rawActivationFees.length,
|
||||||
const metadata = fee.catalogMetadata as { simType?: string } | undefined;
|
sanitizedActivationFeeCount: activationFees.length,
|
||||||
const feeSimType = metadata?.simType;
|
activationFees: activationFees.map(f => ({ id: f.id, sku: f.sku, name: f.name })),
|
||||||
return feeSimType ? feeSimType === simType : fee.sku === selections.activationFeeSku;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (activation) {
|
const activationSelection = this.resolveSimActivationFee(activationFees, selections);
|
||||||
items.push({
|
if (!activationSelection) {
|
||||||
id: activation.id,
|
this.logger.error("SIM activation fee is not available", {
|
||||||
sku: activation.sku,
|
planSku: selections.planSku,
|
||||||
name: activation.name,
|
rawActivationFeeCount: rawActivationFees.length,
|
||||||
description: activation.description,
|
sanitizedActivationFeeCount: activationFees.length,
|
||||||
monthlyPrice: activation.monthlyPrice,
|
|
||||||
oneTimePrice: activation.oneTimePrice,
|
|
||||||
quantity: 1,
|
|
||||||
itemType: "activation",
|
|
||||||
});
|
});
|
||||||
|
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
|
// Add addons
|
||||||
const addonRefs = this.collectAddonRefs(selections);
|
const addonRefs = this.collectAddonRefs(selections);
|
||||||
for (const ref of addonRefs) {
|
for (const ref of addonRefs) {
|
||||||
@ -341,6 +363,57 @@ export class CheckoutService {
|
|||||||
return { items };
|
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
|
* Collect addon references from selections
|
||||||
*/
|
*/
|
||||||
|
|||||||
@ -13,7 +13,7 @@ import {
|
|||||||
sanitizeSoqlLiteral,
|
sanitizeSoqlLiteral,
|
||||||
} from "@bff/integrations/salesforce/utils/soql.util";
|
} from "@bff/integrations/salesforce/utils/soql.util";
|
||||||
|
|
||||||
interface PricebookProductMeta {
|
export interface PricebookProductMeta {
|
||||||
sku: string;
|
sku: string;
|
||||||
pricebookEntryId: string;
|
pricebookEntryId: string;
|
||||||
product2Id?: string;
|
product2Id?: string;
|
||||||
|
|||||||
@ -13,7 +13,8 @@ import {
|
|||||||
import type { Providers } from "@customer-portal/domain/subscriptions";
|
import type { Providers } from "@customer-portal/domain/subscriptions";
|
||||||
|
|
||||||
type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw;
|
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";
|
import { PaymentValidatorService } from "./payment-validator.service";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -29,6 +30,7 @@ export class OrderValidator {
|
|||||||
private readonly mappings: MappingsService,
|
private readonly mappings: MappingsService,
|
||||||
private readonly whmcs: WhmcsConnectionOrchestratorService,
|
private readonly whmcs: WhmcsConnectionOrchestratorService,
|
||||||
private readonly pricebookService: OrderPricebookService,
|
private readonly pricebookService: OrderPricebookService,
|
||||||
|
private readonly simCatalogService: SimCatalogService,
|
||||||
private readonly paymentValidator: PaymentValidatorService
|
private readonly paymentValidator: PaymentValidatorService
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -186,7 +188,10 @@ export class OrderValidator {
|
|||||||
/**
|
/**
|
||||||
* Validate SKUs exist in Salesforce
|
* 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 invalidSKUs: string[] = [];
|
||||||
const meta = await this.pricebookService.fetchProductMeta(pricebookId, skus);
|
const meta = await this.pricebookService.fetchProductMeta(pricebookId, skus);
|
||||||
const normalizedSkus = skus
|
const normalizedSkus = skus
|
||||||
@ -204,6 +209,8 @@ export class OrderValidator {
|
|||||||
this.logger.error({ invalidSKUs }, "Invalid SKUs found in order");
|
this.logger.error({ invalidSKUs }, "Invalid SKUs found in order");
|
||||||
throw new BadRequestException(`Invalid products: ${invalidSKUs.join(", ")}`);
|
throw new BadRequestException(`Invalid products: ${invalidSKUs.join(", ")}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return meta;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -236,6 +243,14 @@ export class OrderValidator {
|
|||||||
const path = issue.path.join(".");
|
const path = issue.path.join(".");
|
||||||
return path ? `${path}: ${issue.message}` : issue.message;
|
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({
|
throw new BadRequestException({
|
||||||
message: "Order business validation failed",
|
message: "Order business validation failed",
|
||||||
errors: issues,
|
errors: issues,
|
||||||
@ -251,7 +266,34 @@ export class OrderValidator {
|
|||||||
|
|
||||||
// 3. SKU validation
|
// 3. SKU validation
|
||||||
const pricebookId = await this.pricebookService.findPortalPricebookId();
|
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
|
// 4. Order-specific business validation
|
||||||
if (businessValidatedBody.orderType === "Internet") {
|
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 { Logger } from "nestjs-pino";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||||
import { SimValidationService } from "./sim-validation.service";
|
import { SimValidationService } from "./sim-validation.service";
|
||||||
import { SimNotificationService } from "./sim-notification.service";
|
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import type { SimCancelRequest } from "@customer-portal/domain/sim";
|
import type { SimCancelRequest } from "@customer-portal/domain/sim";
|
||||||
|
import { SimScheduleService } from "./sim-schedule.service";
|
||||||
|
import { SimActionRunnerService } from "./sim-action-runner.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimCancellationService {
|
export class SimCancellationService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly freebitService: FreebitOrchestratorService,
|
private readonly freebitService: FreebitOrchestratorService,
|
||||||
private readonly simValidation: SimValidationService,
|
private readonly simValidation: SimValidationService,
|
||||||
private readonly simNotification: SimNotificationService,
|
private readonly simSchedule: SimScheduleService,
|
||||||
|
private readonly simActionRunner: SimActionRunnerService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -23,52 +25,44 @@ export class SimCancellationService {
|
|||||||
subscriptionId: number,
|
subscriptionId: number,
|
||||||
request: SimCancelRequest = {}
|
request: SimCancelRequest = {}
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
let account = "";
|
||||||
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
|
||||||
|
|
||||||
// Determine run date (PA02-04 requires runDate); default to 1st of next month
|
await this.simActionRunner.run(
|
||||||
let runDate = request.scheduledAt;
|
"Cancel SIM",
|
||||||
if (runDate && !/^\d{8}$/.test(runDate)) {
|
{
|
||||||
throw new BadRequestException("Scheduled date must be in YYYYMMDD format");
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -2,16 +2,20 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
|||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
||||||
import { SimValidationService } from "./sim-validation.service";
|
import { SimValidationService } from "./sim-validation.service";
|
||||||
import { SimNotificationService } from "./sim-notification.service";
|
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import type { SimPlanChangeRequest, SimFeaturesUpdateRequest } from "@customer-portal/domain/sim";
|
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()
|
@Injectable()
|
||||||
export class SimPlanService {
|
export class SimPlanService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly freebitService: FreebitOrchestratorService,
|
private readonly freebitService: FreebitOrchestratorService,
|
||||||
private readonly simValidation: SimValidationService,
|
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
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -23,91 +27,72 @@ export class SimPlanService {
|
|||||||
subscriptionId: number,
|
subscriptionId: number,
|
||||||
request: SimPlanChangeRequest
|
request: SimPlanChangeRequest
|
||||||
): Promise<{ ipv4?: string; ipv6?: string }> {
|
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||||
|
let account = "";
|
||||||
const assignGlobalIp = request.assignGlobalIp ?? false;
|
const assignGlobalIp = request.assignGlobalIp ?? false;
|
||||||
let scheduledAt: string | undefined;
|
|
||||||
|
|
||||||
try {
|
const response = await this.simActionRunner.run(
|
||||||
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
"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) {
|
||||||
if (!request.newPlanCode || request.newPlanCode.length < 3) {
|
throw new BadRequestException("Invalid plan code");
|
||||||
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 scheduleResolution = this.simSchedule.resolveScheduledDate(request.scheduledAt);
|
||||||
const nextMonth = new Date();
|
|
||||||
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
this.logger.log("Submitting SIM plan change request", {
|
||||||
nextMonth.setDate(1);
|
userId,
|
||||||
const year = nextMonth.getFullYear();
|
subscriptionId,
|
||||||
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
account,
|
||||||
const day = String(nextMonth.getDate()).padStart(2, "0");
|
newPlanCode: request.newPlanCode,
|
||||||
scheduledAt = `${year}${month}${day}`;
|
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", {
|
return {
|
||||||
userId,
|
ipv4: response.ipv4,
|
||||||
subscriptionId,
|
ipv6: response.ipv6,
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -118,93 +103,68 @@ export class SimPlanService {
|
|||||||
subscriptionId: number,
|
subscriptionId: number,
|
||||||
request: SimFeaturesUpdateRequest
|
request: SimFeaturesUpdateRequest
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
try {
|
let account = "";
|
||||||
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
|
||||||
|
|
||||||
// Validate network type if provided
|
await this.simActionRunner.run(
|
||||||
if (request.networkType && !["4G", "5G"].includes(request.networkType)) {
|
"Update Features",
|
||||||
throw new BadRequestException('networkType must be either "4G" or "5G"');
|
{
|
||||||
}
|
baseContext: {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
...request,
|
||||||
|
},
|
||||||
|
enrichSuccess: () => ({
|
||||||
|
account,
|
||||||
|
}),
|
||||||
|
enrichError: () => ({
|
||||||
|
account,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
account = validation.account;
|
||||||
|
|
||||||
const doVoice =
|
if (request.networkType && !["4G", "5G"].includes(request.networkType)) {
|
||||||
typeof request.voiceMailEnabled === "boolean" ||
|
throw new BadRequestException('networkType must be either "4G" or "5G"');
|
||||||
typeof request.callWaitingEnabled === "boolean" ||
|
}
|
||||||
typeof request.internationalRoamingEnabled === "boolean";
|
|
||||||
const doContract = typeof request.networkType === "string";
|
|
||||||
|
|
||||||
if (doVoice && doContract) {
|
const doVoice =
|
||||||
// First apply voice options immediately (PA05-06)
|
typeof request.voiceMailEnabled === "boolean" ||
|
||||||
await this.freebitService.updateSimFeatures(account, {
|
typeof request.callWaitingEnabled === "boolean" ||
|
||||||
voiceMailEnabled: request.voiceMailEnabled,
|
typeof request.internationalRoamingEnabled === "boolean";
|
||||||
callWaitingEnabled: request.callWaitingEnabled,
|
const doContract = typeof request.networkType === "string";
|
||||||
internationalRoamingEnabled: request.internationalRoamingEnabled,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Then schedule contract line change after 30 minutes (PA05-38)
|
if (doVoice && doContract) {
|
||||||
const delayMs = 30 * 60 * 1000;
|
await this.freebitService.updateSimFeatures(account, {
|
||||||
setTimeout(() => {
|
voiceMailEnabled: request.voiceMailEnabled,
|
||||||
this.freebitService
|
callWaitingEnabled: request.callWaitingEnabled,
|
||||||
.updateSimFeatures(account, { networkType: request.networkType })
|
internationalRoamingEnabled: request.internationalRoamingEnabled,
|
||||||
.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);
|
|
||||||
|
|
||||||
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,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
account,
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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 { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
import { Logger } from "nestjs-pino";
|
import { Logger } from "nestjs-pino";
|
||||||
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
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 { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||||
import { SimValidationService } from "./sim-validation.service";
|
import { SimValidationService } from "./sim-validation.service";
|
||||||
import { SimNotificationService } from "./sim-notification.service";
|
|
||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import type { SimTopUpRequest } from "@customer-portal/domain/sim";
|
import type { SimTopUpRequest } from "@customer-portal/domain/sim";
|
||||||
|
import { SimBillingService } from "./sim-billing.service";
|
||||||
|
import { SimActionRunnerService } from "./sim-action-runner.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimTopUpService {
|
export class SimTopUpService {
|
||||||
constructor(
|
constructor(
|
||||||
private readonly freebitService: FreebitOrchestratorService,
|
private readonly freebitService: FreebitOrchestratorService,
|
||||||
private readonly whmcsService: WhmcsService,
|
|
||||||
private readonly mappingsService: MappingsService,
|
private readonly mappingsService: MappingsService,
|
||||||
private readonly simValidation: SimValidationService,
|
private readonly simValidation: SimValidationService,
|
||||||
private readonly simNotification: SimNotificationService,
|
private readonly simBilling: SimBillingService,
|
||||||
|
private readonly simActionRunner: SimActionRunnerService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -24,171 +24,102 @@ export class SimTopUpService {
|
|||||||
* Pricing: 1GB = 500 JPY
|
* Pricing: 1GB = 500 JPY
|
||||||
*/
|
*/
|
||||||
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
||||||
let account: string = "";
|
let latestAccount = "";
|
||||||
|
|
||||||
try {
|
await this.simActionRunner.run(
|
||||||
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
"Top Up Data",
|
||||||
account = validation.account;
|
{
|
||||||
|
baseContext: {
|
||||||
// Validate quota amount
|
userId,
|
||||||
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,
|
|
||||||
subscriptionId,
|
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
|
const billing = await this.simBilling.createOneTimeCharge({
|
||||||
await this.handlePaymentFailure(invoice.id, paymentResult.error || "Unknown payment error");
|
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}`);
|
try {
|
||||||
}
|
await this.freebitService.topUpSim(latestAccount, request.quotaMb, {});
|
||||||
|
} catch (freebitError) {
|
||||||
this.logger.log(`Payment captured successfully for invoice ${invoice.id}`, {
|
await this.handleFreebitFailureAfterPayment(
|
||||||
invoiceId: invoice.id,
|
freebitError,
|
||||||
transactionId: paymentResult.transactionId,
|
billing.invoice,
|
||||||
amount: costJpy,
|
billing.transactionId || "unknown",
|
||||||
subscriptionId,
|
userId,
|
||||||
});
|
subscriptionId,
|
||||||
|
latestAccount,
|
||||||
try {
|
request.quotaMb
|
||||||
// Step 3: Only if payment successful, add data via Freebit
|
);
|
||||||
await this.freebitService.topUpSim(account, request.quotaMb, {});
|
}
|
||||||
|
|
||||||
this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, {
|
this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, {
|
||||||
userId,
|
userId,
|
||||||
subscriptionId,
|
subscriptionId,
|
||||||
account,
|
account: latestAccount,
|
||||||
quotaMb: request.quotaMb,
|
quotaMb: request.quotaMb,
|
||||||
costJpy,
|
costJpy,
|
||||||
invoiceId: invoice.id,
|
invoiceId: billing.invoice.id,
|
||||||
transactionId: paymentResult.transactionId,
|
transactionId: billing.transactionId,
|
||||||
});
|
});
|
||||||
|
|
||||||
await this.simNotification.notifySimAction("Top Up Data", "SUCCESS", {
|
return {
|
||||||
userId,
|
account: latestAccount,
|
||||||
subscriptionId,
|
|
||||||
account,
|
|
||||||
quotaMb: request.quotaMb,
|
|
||||||
costJpy,
|
costJpy,
|
||||||
invoiceId: invoice.id,
|
invoiceId: billing.invoice.id,
|
||||||
transactionId: paymentResult.transactionId,
|
transactionId: billing.transactionId,
|
||||||
});
|
};
|
||||||
} catch (freebitError) {
|
|
||||||
// If Freebit fails after payment, handle carefully
|
|
||||||
await this.handleFreebitFailureAfterPayment(
|
|
||||||
freebitError,
|
|
||||||
invoice,
|
|
||||||
paymentResult.transactionId || "unknown",
|
|
||||||
userId,
|
|
||||||
subscriptionId,
|
|
||||||
account,
|
|
||||||
request.quotaMb
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} 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
|
// Add a note to the invoice about the Freebit failure
|
||||||
try {
|
try {
|
||||||
await this.whmcsService.updateInvoice({
|
await this.simBilling.appendInvoiceNote(
|
||||||
invoiceId: invoice.id,
|
invoice.id,
|
||||||
notes: `Payment successful but SIM top-up failed: ${getErrorMessage(freebitError)}. Manual intervention required.`,
|
`Payment successful but SIM top-up failed: ${getErrorMessage(
|
||||||
});
|
freebitError
|
||||||
|
)}. Manual intervention required.`
|
||||||
|
);
|
||||||
|
|
||||||
this.logger.log(`Added failure note to invoice ${invoice.id}`, {
|
this.logger.log(`Added failure note to invoice ${invoice.id}`, {
|
||||||
invoiceId: invoice.id,
|
invoiceId: invoice.id,
|
||||||
@ -241,16 +174,8 @@ export class SimTopUpService {
|
|||||||
// to ensure consistency across all failure scenarios.
|
// to ensure consistency across all failure scenarios.
|
||||||
// For manual refunds, use the WHMCS admin panel or dedicated refund endpoints.
|
// 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.`;
|
throw new Error(
|
||||||
await this.simNotification.notifySimAction("Top Up Data", "ERROR", {
|
`Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`
|
||||||
userId,
|
);
|
||||||
subscriptionId,
|
|
||||||
account,
|
|
||||||
quotaMb,
|
|
||||||
invoiceId: invoice.id,
|
|
||||||
transactionId,
|
|
||||||
error: getErrorMessage(freebitError),
|
|
||||||
});
|
|
||||||
throw new Error(errMsg);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -6,7 +6,7 @@ import { SimUsageStoreService } from "../../sim-usage-store.service";
|
|||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import type { SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim";
|
import type { SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim";
|
||||||
import type { SimTopUpHistoryRequest } 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()
|
@Injectable()
|
||||||
export class SimUsageService {
|
export class SimUsageService {
|
||||||
@ -14,6 +14,7 @@ export class SimUsageService {
|
|||||||
private readonly freebitService: FreebitOrchestratorService,
|
private readonly freebitService: FreebitOrchestratorService,
|
||||||
private readonly simValidation: SimValidationService,
|
private readonly simValidation: SimValidationService,
|
||||||
private readonly usageStore: SimUsageStoreService,
|
private readonly usageStore: SimUsageStoreService,
|
||||||
|
private readonly simSchedule: SimScheduleService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -76,15 +77,13 @@ export class SimUsageService {
|
|||||||
try {
|
try {
|
||||||
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
// Validate date format
|
const fromDate = this.simSchedule.ensureYyyyMmDd(request.fromDate, "fromDate");
|
||||||
if (!/^\d{8}$/.test(request.fromDate) || !/^\d{8}$/.test(request.toDate)) {
|
const toDate = this.simSchedule.ensureYyyyMmDd(request.toDate, "toDate");
|
||||||
throw new BadRequestException("Dates must be in YYYYMMDD format");
|
|
||||||
}
|
|
||||||
|
|
||||||
const history = await this.freebitService.getSimTopUpHistory(
|
const history = await this.freebitService.getSimTopUpHistory(
|
||||||
account,
|
account,
|
||||||
request.fromDate,
|
fromDate,
|
||||||
request.toDate
|
toDate
|
||||||
);
|
);
|
||||||
|
|
||||||
this.logger.log(`Retrieved SIM top-up history for subscription ${subscriptionId}`, {
|
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 { EsimManagementService } from "./services/esim-management.service";
|
||||||
import { SimValidationService } from "./services/sim-validation.service";
|
import { SimValidationService } from "./services/sim-validation.service";
|
||||||
import { SimNotificationService } from "./services/sim-notification.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({
|
@Module({
|
||||||
imports: [FreebitModule, WhmcsModule, MappingsModule, EmailModule],
|
imports: [FreebitModule, WhmcsModule, MappingsModule, EmailModule],
|
||||||
@ -34,6 +39,11 @@ import { SimNotificationService } from "./services/sim-notification.service";
|
|||||||
SimCancellationService,
|
SimCancellationService,
|
||||||
EsimManagementService,
|
EsimManagementService,
|
||||||
SimOrchestratorService,
|
SimOrchestratorService,
|
||||||
|
SimBillingService,
|
||||||
|
SimScheduleService,
|
||||||
|
SimActionRunnerService,
|
||||||
|
SimManagementQueueService,
|
||||||
|
SimManagementProcessor,
|
||||||
],
|
],
|
||||||
exports: [
|
exports: [
|
||||||
SimOrchestratorService,
|
SimOrchestratorService,
|
||||||
@ -46,6 +56,10 @@ import { SimNotificationService } from "./services/sim-notification.service";
|
|||||||
EsimManagementService,
|
EsimManagementService,
|
||||||
SimValidationService,
|
SimValidationService,
|
||||||
SimNotificationService,
|
SimNotificationService,
|
||||||
|
SimBillingService,
|
||||||
|
SimScheduleService,
|
||||||
|
SimActionRunnerService,
|
||||||
|
SimManagementQueueService,
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
export class SimManagementModule {}
|
export class SimManagementModule {}
|
||||||
|
|||||||
@ -7,6 +7,8 @@ import { CacheService } from "@bff/infra/cache/cache.service";
|
|||||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||||
import type { SimOrderActivationRequest } from "@customer-portal/domain/sim";
|
import type { SimOrderActivationRequest } from "@customer-portal/domain/sim";
|
||||||
import { randomUUID } from "crypto";
|
import { randomUUID } from "crypto";
|
||||||
|
import { SimBillingService } from "./sim-management/services/sim-billing.service";
|
||||||
|
import { SimScheduleService } from "./sim-management/services/sim-schedule.service";
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class SimOrderActivationService {
|
export class SimOrderActivationService {
|
||||||
@ -15,6 +17,8 @@ export class SimOrderActivationService {
|
|||||||
private readonly whmcs: WhmcsService,
|
private readonly whmcs: WhmcsService,
|
||||||
private readonly mappings: MappingsService,
|
private readonly mappings: MappingsService,
|
||||||
private readonly cache: CacheService,
|
private readonly cache: CacheService,
|
||||||
|
private readonly simBilling: SimBillingService,
|
||||||
|
private readonly simSchedule: SimScheduleService,
|
||||||
@Inject(Logger) private readonly logger: Logger
|
@Inject(Logger) private readonly logger: Logger
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
@ -51,69 +55,82 @@ export class SimOrderActivationService {
|
|||||||
|
|
||||||
// Mark as processing (5 minute TTL)
|
// Mark as processing (5 minute TTL)
|
||||||
await this.cache.set(processingKey, true, 300);
|
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)) {
|
if (req.simType === "eSIM" && (!req.eid || req.eid.length < 15)) {
|
||||||
|
await releaseProcessingFlag();
|
||||||
throw new BadRequestException("EID is required for eSIM and must be valid");
|
throw new BadRequestException("EID is required for eSIM and must be valid");
|
||||||
}
|
}
|
||||||
if (!req.msisdn || req.msisdn.trim() === "") {
|
if (!req.msisdn || req.msisdn.trim() === "") {
|
||||||
|
await releaseProcessingFlag();
|
||||||
throw new BadRequestException("Phone number (msisdn) is required for SIM activation");
|
throw new BadRequestException("Phone number (msisdn) is required for SIM activation");
|
||||||
}
|
}
|
||||||
if (!/^\d{8}$/.test(req.scheduledAt || "") && req.activationType === "Scheduled") {
|
try {
|
||||||
throw new BadRequestException("scheduledAt must be YYYYMMDD when scheduling activation");
|
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);
|
const mapping = await this.mappings.findByUserId(userId);
|
||||||
if (!mapping?.whmcsClientId) {
|
if (!mapping?.whmcsClientId) {
|
||||||
|
await releaseProcessingFlag();
|
||||||
throw new BadRequestException("WHMCS client mapping not found");
|
throw new BadRequestException("WHMCS client mapping not found");
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1) Create invoice for one-time activation fee only
|
let billingResult:
|
||||||
const invoice = await this.whmcs.createInvoice({
|
| {
|
||||||
clientId: mapping.whmcsClientId,
|
invoice: { id: number; number: string };
|
||||||
description: `SIM Activation Fee (${req.planSku}) for ${req.msisdn}`,
|
transactionId?: string;
|
||||||
amount: req.oneTimeAmountJpy,
|
}
|
||||||
currency: "JPY",
|
| undefined;
|
||||||
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.`,
|
|
||||||
});
|
|
||||||
|
|
||||||
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 {
|
try {
|
||||||
if (req.simType === "eSIM") {
|
billingResult = await this.simBilling.createOneTimeCharge({
|
||||||
await this.freebit.activateEsimAccountNew({
|
clientId: mapping.whmcsClientId,
|
||||||
account: req.msisdn,
|
description: `SIM Activation Fee (${req.planSku}) for ${req.msisdn}`,
|
||||||
eid: req.eid!,
|
amountJpy: req.oneTimeAmountJpy,
|
||||||
planCode: req.planSku,
|
currency: "JPY",
|
||||||
contractLine: "5G",
|
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||||
shipDate: req.activationType === "Scheduled" ? req.scheduledAt : undefined,
|
notes: `SIM activation fee for ${req.msisdn}, plan ${req.planSku}. Monthly billing will start on the 1st of next month.`,
|
||||||
mnp: req.mnp
|
failureNotesPrefix: "SIM activation payment failed",
|
||||||
? {
|
publicErrorMessage: "Unable to process SIM activation payment. Please update your payment method.",
|
||||||
reserveNumber: req.mnp.reserveNumber || "",
|
metadata: { userId, msisdn: req.msisdn },
|
||||||
reserveExpireDate: req.mnp.reserveExpireDate || "",
|
});
|
||||||
}
|
|
||||||
: undefined,
|
await this.freebit.activateEsimAccountNew({
|
||||||
});
|
account: req.msisdn,
|
||||||
} else {
|
eid: req.eid!,
|
||||||
this.logger.warn("Physical SIM activation path is not implemented; skipping Freebit call", {
|
planCode: req.planSku,
|
||||||
account: req.msisdn,
|
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)) {
|
if (req.addons && (req.addons.voiceMail || req.addons.callWaiting)) {
|
||||||
await this.freebit.updateSimFeatures(req.msisdn, {
|
await this.freebit.updateSimFeatures(req.msisdn, {
|
||||||
voiceMailEnabled: !!req.addons.voiceMail,
|
voiceMailEnabled: !!req.addons.voiceMail,
|
||||||
@ -121,25 +138,19 @@ export class SimOrderActivationService {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4) Create monthly subscription for recurring billing
|
|
||||||
if (req.monthlyAmountJpy > 0) {
|
if (req.monthlyAmountJpy > 0) {
|
||||||
const nextMonth = new Date();
|
const nextBillingIso = this.simSchedule.firstDayOfNextMonthIsoDate();
|
||||||
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 orderService = this.whmcs.getOrderService();
|
const orderService = this.whmcs.getOrderService();
|
||||||
await orderService.addOrder({
|
await orderService.addOrder({
|
||||||
clientId: mapping.whmcsClientId,
|
clientId: mapping.whmcsClientId,
|
||||||
items: [
|
items: [
|
||||||
{
|
{
|
||||||
productId: req.planSku, // Use the plan SKU as product ID
|
productId: req.planSku,
|
||||||
billingCycle: "monthly",
|
billingCycle: "monthly",
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
configOptions: {
|
configOptions: {
|
||||||
phone_number: req.msisdn,
|
phone_number: req.msisdn,
|
||||||
activation_date: nextMonth.toISOString().split("T")[0],
|
activation_date: nextBillingIso,
|
||||||
},
|
},
|
||||||
customFields: {
|
customFields: {
|
||||||
sim_type: req.simType,
|
sim_type: req.simType,
|
||||||
@ -147,43 +158,42 @@ export class SimOrderActivationService {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
paymentMethod: "mailin", // Default payment method
|
paymentMethod: "mailin",
|
||||||
notes: `Monthly SIM plan billing for ${req.msisdn}, plan ${req.planSku}. Billing starts on the 1st of next month.`,
|
notes: `Monthly SIM plan billing for ${req.msisdn}, plan ${req.planSku}. Billing starts on ${nextBillingIso}.`,
|
||||||
noinvoice: false, // Create invoice
|
noinvoice: false,
|
||||||
noinvoiceemail: true, // Suppress invoice email for now
|
noinvoiceemail: true,
|
||||||
noemail: true, // Suppress order emails
|
noemail: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
this.logger.log("Monthly subscription created", {
|
this.logger.log("Monthly subscription created", {
|
||||||
account: req.msisdn,
|
account: req.msisdn,
|
||||||
amount: req.monthlyAmountJpy,
|
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 = {
|
const result = {
|
||||||
success: true,
|
success: true,
|
||||||
invoiceId: invoice.id,
|
invoiceId: billingResult.invoice.id,
|
||||||
transactionId: paymentResult.transactionId,
|
transactionId: billingResult.transactionId,
|
||||||
};
|
};
|
||||||
|
|
||||||
// Cache successful result for 24 hours
|
|
||||||
await this.cache.set(cacheKey, result, 86400);
|
await this.cache.set(cacheKey, result, 86400);
|
||||||
|
await releaseProcessingFlag();
|
||||||
// Remove processing flag
|
|
||||||
await this.cache.del(processingKey);
|
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Remove processing flag on error
|
await releaseProcessingFlag();
|
||||||
await this.cache.del(processingKey);
|
if (billingResult?.invoice) {
|
||||||
|
await this.simBilling.appendInvoiceNote(
|
||||||
await this.whmcs.updateInvoice({
|
billingResult.invoice.id,
|
||||||
invoiceId: invoice.id,
|
`Freebit activation failed after payment: ${getErrorMessage(err)}`
|
||||||
notes: `Freebit activation failed after payment: ${getErrorMessage(err)}`,
|
);
|
||||||
});
|
}
|
||||||
throw err;
|
throw err;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -136,7 +136,7 @@ const NavigationItem = memo(function NavigationItem({
|
|||||||
const isChildActive = pathname === (child.href || "").split(/[?#]/)[0];
|
const isChildActive = pathname === (child.href || "").split(/[?#]/)[0];
|
||||||
return (
|
return (
|
||||||
<Link
|
<Link
|
||||||
key={child.name}
|
key={child.href || child.name}
|
||||||
href={child.href}
|
href={child.href}
|
||||||
prefetch
|
prefetch
|
||||||
onMouseEnter={() => {
|
onMouseEnter={() => {
|
||||||
|
|||||||
@ -1,100 +1,129 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { CardPricing } from "@/features/catalog/components/base/CardPricing";
|
||||||
|
|
||||||
|
interface ActivationFeeDetails {
|
||||||
|
amount: number;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ActivationFormProps {
|
interface ActivationFormProps {
|
||||||
activationType: "Immediate" | "Scheduled";
|
activationType: "Immediate" | "Scheduled";
|
||||||
onActivationTypeChange: (type: "Immediate" | "Scheduled") => void;
|
onActivationTypeChange: (type: "Immediate" | "Scheduled") => void;
|
||||||
scheduledActivationDate: string;
|
scheduledActivationDate: string;
|
||||||
onScheduledActivationDateChange: (date: string) => void;
|
onScheduledActivationDateChange: (date: string) => void;
|
||||||
errors: Record<string, string | undefined>;
|
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({
|
export function ActivationForm({
|
||||||
activationType,
|
activationType,
|
||||||
onActivationTypeChange,
|
onActivationTypeChange,
|
||||||
scheduledActivationDate,
|
scheduledActivationDate,
|
||||||
onScheduledActivationDateChange,
|
onScheduledActivationDateChange,
|
||||||
errors,
|
errors,
|
||||||
|
activationFee,
|
||||||
}: ActivationFormProps) {
|
}: ActivationFormProps) {
|
||||||
const sharedLabelClasses =
|
|
||||||
"flex items-start gap-3 p-4 rounded-lg border-2 cursor-pointer transition-colors duration-200 ease-in-out";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||||
<label
|
{activationOptions.map(option => {
|
||||||
className={`${sharedLabelClasses} ${
|
const isSelected = activationType === option.type;
|
||||||
activationType === "Immediate"
|
return (
|
||||||
? "border-blue-500 bg-blue-50 shadow-sm ring-1 ring-blue-200"
|
<label
|
||||||
: "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
|
||||||
<input
|
? "border-blue-500 bg-blue-50 shadow-md"
|
||||||
type="radio"
|
: "border-gray-200 hover:border-blue-400 hover:bg-blue-50/50 shadow-sm hover:shadow-md"
|
||||||
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'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"
|
|
||||||
}`}
|
}`}
|
||||||
aria-hidden={activationType !== "Scheduled"}
|
|
||||||
>
|
>
|
||||||
<div className="mt-3">
|
<input
|
||||||
<label
|
type="radio"
|
||||||
htmlFor="scheduledActivationDate"
|
name="activationType"
|
||||||
className="block text-sm font-medium text-gray-700 mb-1"
|
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 *
|
{isSelected && <div className="w-2 h-2 bg-white rounded-full" />}
|
||||||
</label>
|
</div>
|
||||||
<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>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
</div>
|
<p className="text-sm text-gray-600">{option.description}</p>
|
||||||
</label>
|
|
||||||
|
{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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,6 +18,7 @@ import {
|
|||||||
UsersIcon,
|
UsersIcon,
|
||||||
} from "@heroicons/react/24/outline";
|
} from "@heroicons/react/24/outline";
|
||||||
import type { UseSimConfigureResult } from "@/features/catalog/hooks/useSimConfigure";
|
import type { UseSimConfigureResult } from "@/features/catalog/hooks/useSimConfigure";
|
||||||
|
import type { SimActivationFeeCatalogItem } from "@customer-portal/domain/catalog";
|
||||||
|
|
||||||
type Props = UseSimConfigureResult & {
|
type Props = UseSimConfigureResult & {
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
@ -47,6 +48,38 @@ export function SimConfigureView({
|
|||||||
setCurrentStep,
|
setCurrentStep,
|
||||||
onConfirm,
|
onConfirm,
|
||||||
}: Props) {
|
}: 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)
|
// Calculate display totals from catalog prices (for display only)
|
||||||
// Note: BFF will recalculate authoritative pricing
|
// Note: BFF will recalculate authoritative pricing
|
||||||
const monthlyTotal =
|
const monthlyTotal =
|
||||||
@ -58,6 +91,7 @@ export function SimConfigureView({
|
|||||||
|
|
||||||
const oneTimeTotal =
|
const oneTimeTotal =
|
||||||
(plan?.oneTimePrice ?? 0) +
|
(plan?.oneTimePrice ?? 0) +
|
||||||
|
activationFeeAmount +
|
||||||
selectedAddons.reduce((sum, addonSku) => {
|
selectedAddons.reduce((sum, addonSku) => {
|
||||||
const addon = addons.find(a => a.sku === addonSku);
|
const addon = addons.find(a => a.sku === addonSku);
|
||||||
return sum + (addon?.oneTimePrice ?? 0);
|
return sum + (addon?.oneTimePrice ?? 0);
|
||||||
@ -262,6 +296,7 @@ export function SimConfigureView({
|
|||||||
scheduledActivationDate={scheduledActivationDate}
|
scheduledActivationDate={scheduledActivationDate}
|
||||||
onScheduledActivationDateChange={setScheduledActivationDate}
|
onScheduledActivationDateChange={setScheduledActivationDate}
|
||||||
errors={{}}
|
errors={{}}
|
||||||
|
activationFee={activationFeeDetails}
|
||||||
/>
|
/>
|
||||||
<div className="flex justify-between mt-6">
|
<div className="flex justify-between mt-6">
|
||||||
<Button
|
<Button
|
||||||
@ -467,24 +502,25 @@ export function SimConfigureView({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{activationFees.length > 0 &&
|
{activationFeeDetails && (
|
||||||
activationFees.some(fee => (fee.oneTimePrice ?? fee.unitPrice ?? 0) > 0) && (
|
<div className="border-t border-gray-200 pt-4 mb-6">
|
||||||
<div className="border-t border-gray-200 pt-4 mb-6">
|
<h4 className="font-medium text-gray-900 mb-3">One-time Fees</h4>
|
||||||
<h4 className="font-medium text-gray-900 mb-3">One-time Fees</h4>
|
<div className="space-y-2">
|
||||||
<div className="space-y-2">
|
<div className="flex justify-between text-sm">
|
||||||
{activationFees.map((fee, index) => {
|
<span className="text-gray-600">{activationFeeDetails.name}</span>
|
||||||
const feeAmount =
|
<span className="text-gray-900">
|
||||||
fee.oneTimePrice ?? fee.unitPrice ?? fee.monthlyPrice ?? 0;
|
¥{activationFeeDetails.amount.toLocaleString()}
|
||||||
return (
|
</span>
|
||||||
<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>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
</div>
|
||||||
|
{(requiredActivationFee?.catalogMetadata?.autoAdd ||
|
||||||
|
requiredActivationFee?.catalogMetadata?.isDefault) && (
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
Required for all new SIM activations
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</div>
|
</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="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">
|
<div className="space-y-2">
|
||||||
|
|||||||
@ -15,9 +15,14 @@ import {
|
|||||||
} from "@customer-portal/domain/toolkit";
|
} from "@customer-portal/domain/toolkit";
|
||||||
import type { AsyncState } from "@customer-portal/domain/toolkit";
|
import type { AsyncState } from "@customer-portal/domain/toolkit";
|
||||||
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
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 { 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
|
// Use domain Address type
|
||||||
import type { Address } from "@customer-portal/domain/customer";
|
import type { Address } from "@customer-portal/domain/customer";
|
||||||
@ -141,6 +146,11 @@ export function useCheckout() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const cart = checkoutState.data;
|
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(
|
const uniqueSkus = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
cart.items
|
cart.items
|
||||||
@ -149,6 +159,8 @@ export function useCheckout() {
|
|||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
console.log("[DEBUG] Extracted SKUs from cart:", uniqueSkus);
|
||||||
|
|
||||||
if (uniqueSkus.length === 0) {
|
if (uniqueSkus.length === 0) {
|
||||||
throw new Error("No products selected for order. Please go back and select products.");
|
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);
|
const response = await ordersService.createOrder(orderData);
|
||||||
router.push(`/orders/${response.sfOrderId}?status=success`);
|
router.push(`/orders/${response.sfOrderId}?status=success`);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
|||||||
@ -14,11 +14,29 @@ async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: st
|
|||||||
skus: payload.skus,
|
skus: payload.skus,
|
||||||
...(payload.configurations ? { configurations: payload.configurations } : {}),
|
...(payload.configurations ? { configurations: payload.configurations } : {}),
|
||||||
};
|
};
|
||||||
const response = await apiClient.POST("/api/orders", { body });
|
|
||||||
const parsed = assertSuccess<{ sfOrderId: string; status: string; message: string }>(
|
// Debug logging
|
||||||
response.data as DomainApiResponse<{ sfOrderId: string; status: string; message: string }>
|
console.log("[DEBUG] Creating order with payload:", JSON.stringify(body, null, 2));
|
||||||
);
|
console.log("[DEBUG] Order Type:", body.orderType);
|
||||||
return { sfOrderId: parsed.data.sfOrderId };
|
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[]> {
|
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
|
pricebookEntry?: SalesforcePricebookEntryRecord
|
||||||
): SimActivationFeeCatalogItem {
|
): SimActivationFeeCatalogItem {
|
||||||
const simProduct = mapSimProduct(product, pricebookEntry);
|
const simProduct = mapSimProduct(product, pricebookEntry);
|
||||||
|
const isDefault = product.Is_Default__c === true;
|
||||||
|
const autoAdd = product.Auto_Add__c === true;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...simProduct,
|
...simProduct,
|
||||||
catalogMetadata: {
|
catalogMetadata: {
|
||||||
isDefault: true,
|
...(simProduct.catalogMetadata ?? {}),
|
||||||
|
isDefault,
|
||||||
|
autoAdd,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,6 +21,9 @@ export const salesforceProduct2RecordSchema = z.object({
|
|||||||
Item_Class__c: z.string().nullable().optional(),
|
Item_Class__c: z.string().nullable().optional(),
|
||||||
Billing_Cycle__c: z.string().nullable().optional(),
|
Billing_Cycle__c: z.string().nullable().optional(),
|
||||||
Catalog_Order__c: z.number().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(),
|
Bundled_Addon__c: z.string().nullable().optional(),
|
||||||
Is_Bundled_Addon__c: z.boolean().nullable().optional(),
|
Is_Bundled_Addon__c: z.boolean().nullable().optional(),
|
||||||
Internet_Plan_Tier__c: z.string().nullable().optional(),
|
Internet_Plan_Tier__c: z.string().nullable().optional(),
|
||||||
|
|||||||
@ -92,9 +92,12 @@ export const simCatalogProductSchema = catalogProductBaseSchema.extend({
|
|||||||
});
|
});
|
||||||
|
|
||||||
export const simActivationFeeCatalogItemSchema = simCatalogProductSchema.extend({
|
export const simActivationFeeCatalogItemSchema = simCatalogProductSchema.extend({
|
||||||
catalogMetadata: z.object({
|
catalogMetadata: z
|
||||||
isDefault: z.boolean(),
|
.object({
|
||||||
}).optional(),
|
isDefault: z.boolean().optional(),
|
||||||
|
autoAdd: z.boolean().optional(),
|
||||||
|
})
|
||||||
|
.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export const simCatalogCollectionSchema = z.object({
|
export const simCatalogCollectionSchema = z.object({
|
||||||
|
|||||||
@ -28,12 +28,9 @@ export function hasSimServicePlan(skus: string[]): boolean {
|
|||||||
/**
|
/**
|
||||||
* Check if SKUs array contains a SIM activation fee
|
* Check if SKUs array contains a SIM activation fee
|
||||||
*/
|
*/
|
||||||
export function hasSimActivationFee(skus: string[]): boolean {
|
export function hasSimActivationFee(_skus: string[]): boolean {
|
||||||
return skus.some(
|
// Deprecated: rely on catalog metadata instead of heuristics.
|
||||||
(sku) =>
|
return true;
|
||||||
sku.toUpperCase().includes("ACTIVATION") ||
|
|
||||||
sku.toUpperCase().includes("SIM-ACTIVATION")
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -100,13 +97,6 @@ export const orderWithSkuValidationSchema = orderBusinessValidationSchema
|
|||||||
path: ["skus"],
|
path: ["skus"],
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
.refine(
|
|
||||||
(data) => data.orderType !== "SIM" || hasSimActivationFee(data.skus),
|
|
||||||
{
|
|
||||||
message: "SIM orders require an activation fee",
|
|
||||||
path: ["skus"],
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.refine(
|
.refine(
|
||||||
(data) => data.orderType !== "VPN" || hasVpnActivationFee(data.skus),
|
(data) => data.orderType !== "VPN" || hasVpnActivationFee(data.skus),
|
||||||
{
|
{
|
||||||
@ -137,11 +127,7 @@ export function getOrderTypeValidationError(orderType: string, skus: string[]):
|
|||||||
if (!hasSimServicePlan(skus)) {
|
if (!hasSimServicePlan(skus)) {
|
||||||
return "A SIM plan must be selected";
|
return "A SIM plan must be selected";
|
||||||
}
|
}
|
||||||
if (!hasSimActivationFee(skus)) {
|
|
||||||
return "SIM orders require an activation fee";
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
|
|
||||||
case "VPN":
|
case "VPN":
|
||||||
if (!hasVpnActivationFee(skus)) {
|
if (!hasVpnActivationFee(skus)) {
|
||||||
return "VPN orders require an activation fee";
|
return "VPN orders require an activation fee";
|
||||||
|
|||||||
@ -11,6 +11,7 @@ export { SIM_STATUS, SIM_TYPE } from "./contract";
|
|||||||
|
|
||||||
// Schemas (includes derived types)
|
// Schemas (includes derived types)
|
||||||
export * from "./schema";
|
export * from "./schema";
|
||||||
|
export * from "./lifecycle";
|
||||||
|
|
||||||
// Validation functions
|
// Validation functions
|
||||||
export * from "./validation";
|
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