From c741ece8448baa169757287635cd5f4559ffe5fb Mon Sep 17 00:00:00 2001 From: barsa Date: Tue, 18 Nov 2025 10:57:36 +0900 Subject: [PATCH] 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. --- apps/bff/src/infra/queue/queue.constants.ts | 1 + apps/bff/src/infra/queue/queue.module.ts | 3 +- .../catalog/services/sim-catalog.service.ts | 40 ++- .../orders/services/checkout.service.spec.ts | 161 ++++++++++ .../orders/services/checkout.service.ts | 109 +++++-- .../services/order-pricebook.service.ts | 2 +- .../services/order-validator.service.ts | 48 ++- .../queue/sim-management.processor.ts | 57 ++++ .../queue/sim-management.queue.ts | 50 ++++ .../services/sim-action-runner.service.ts | 40 +++ .../services/sim-billing.service.ts | 108 +++++++ .../services/sim-cancellation.service.ts | 88 +++--- .../services/sim-plan.service.ts | 278 ++++++++---------- .../services/sim-schedule.service.ts | 72 +++++ .../services/sim-topup.service.ts | 263 ++++++----------- .../services/sim-usage.service.ts | 13 +- .../sim-management/sim-management.module.ts | 14 + .../sim-order-activation.service.ts | 164 ++++++----- .../components/organisms/AppShell/Sidebar.tsx | 2 +- .../catalog/components/sim/ActivationForm.tsx | 183 +++++++----- .../components/sim/SimConfigureView.tsx | 68 ++++- .../features/checkout/hooks/useCheckout.ts | 32 +- .../orders/services/orders.service.ts | 28 +- docs/sim/STATE_MACHINE.md | 81 +++++ .../catalog/providers/salesforce/mapper.ts | 6 +- .../catalog/providers/salesforce/raw.types.ts | 3 + packages/domain/catalog/schema.ts | 9 +- packages/domain/orders/validation.ts | 20 +- packages/domain/sim/index.ts | 1 + packages/domain/sim/lifecycle.ts | 141 +++++++++ 30 files changed, 1476 insertions(+), 609 deletions(-) create mode 100644 apps/bff/src/modules/subscriptions/sim-management/queue/sim-management.processor.ts create mode 100644 apps/bff/src/modules/subscriptions/sim-management/queue/sim-management.queue.ts create mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-action-runner.service.ts create mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts create mode 100644 apps/bff/src/modules/subscriptions/sim-management/services/sim-schedule.service.ts create mode 100644 docs/sim/STATE_MACHINE.md create mode 100644 packages/domain/sim/lifecycle.ts diff --git a/apps/bff/src/infra/queue/queue.constants.ts b/apps/bff/src/infra/queue/queue.constants.ts index 522021ca..703337a7 100644 --- a/apps/bff/src/infra/queue/queue.constants.ts +++ b/apps/bff/src/infra/queue/queue.constants.ts @@ -2,6 +2,7 @@ export const QUEUE_NAMES = { EMAIL: "email", PROVISIONING: "provisioning", RECONCILE: "reconcile", + SIM_MANAGEMENT: "sim-management", } as const; export type QueueName = (typeof QUEUE_NAMES)[keyof typeof QUEUE_NAMES]; diff --git a/apps/bff/src/infra/queue/queue.module.ts b/apps/bff/src/infra/queue/queue.module.ts index 7c7af431..06c1b550 100644 --- a/apps/bff/src/infra/queue/queue.module.ts +++ b/apps/bff/src/infra/queue/queue.module.ts @@ -33,7 +33,8 @@ function parseRedisConnection(redisUrl: string) { BullModule.registerQueue( { name: QUEUE_NAMES.EMAIL }, { name: QUEUE_NAMES.PROVISIONING }, - { name: QUEUE_NAMES.RECONCILE } + { name: QUEUE_NAMES.RECONCILE }, + { name: QUEUE_NAMES.SIM_MANAGEMENT } ), ], exports: [BullModule], diff --git a/apps/bff/src/modules/catalog/services/sim-catalog.service.ts b/apps/bff/src/modules/catalog/services/sim-catalog.service.ts index ce412ac5..7f89d40d 100644 --- a/apps/bff/src/modules/catalog/services/sim-catalog.service.ts +++ b/apps/bff/src/modules/catalog/services/sim-catalog.service.ts @@ -67,16 +67,46 @@ export class SimCatalogService extends BaseCatalogService { return this.catalogCache.getCachedCatalog( cacheKey, async () => { - const soql = this.buildProductQuery("SIM", "Activation", []); + const soql = this.buildProductQuery("SIM", "Activation", [ + "Catalog_Order__c", + "Auto_Add__c", + "Is_Default__c", + ]); const records = await this.executeQuery( soql, "SIM Activation Fees" ); - return records.map(record => { - const entry = this.extractPricebookEntry(record); - return CatalogProviders.Salesforce.mapSimActivationFee(record, entry); - }); + const activationFees = records + .map(record => { + const entry = this.extractPricebookEntry(record); + return CatalogProviders.Salesforce.mapSimActivationFee(record, entry); + }) + .sort((a, b) => (a.displayOrder ?? 0) - (b.displayOrder ?? 0)); + + if (activationFees.length === 0) { + this.logger.warn("No SIM activation fees found in catalog"); + return activationFees; + } + + const hasDefault = activationFees.some( + fee => fee.catalogMetadata?.isDefault === true + ); + + if (!hasDefault) { + this.logger.warn( + "No default SIM activation fee configured. Marking the first fee as default." + ); + activationFees[0] = { + ...activationFees[0], + catalogMetadata: { + ...activationFees[0].catalogMetadata, + isDefault: true, + }, + }; + } + + return activationFees; }, { resolveDependencies: products => ({ diff --git a/apps/bff/src/modules/orders/services/checkout.service.spec.ts b/apps/bff/src/modules/orders/services/checkout.service.spec.ts index 16be95e2..54773181 100644 --- a/apps/bff/src/modules/orders/services/checkout.service.spec.ts +++ b/apps/bff/src/modules/orders/services/checkout.service.spec.ts @@ -10,6 +10,7 @@ import type { VpnCatalogService } from "@bff/modules/catalog/services/vpn-catalo const createLogger = (): Logger => ({ log: jest.fn(), + warn: jest.fn(), error: jest.fn(), }) as unknown as Logger; @@ -22,6 +23,41 @@ const internetPlan = { oneTimePrice: 0, } as unknown; +const simPlan = { + id: "sim-1", + sku: "SIM-PLAN-1", + name: "SIM Plan", + description: "SIM Plan", + monthlyPrice: 4500, + oneTimePrice: 0, +} as unknown; + +const defaultActivationFee = { + id: "act-1", + sku: "SIM-ACTIVATION-FEE", + name: "SIM Activation Fee", + description: "One-time fee", + monthlyPrice: 0, + oneTimePrice: 3000, + catalogMetadata: { + autoAdd: true, + isDefault: true, + }, +} as unknown; + +const alternateActivationFee = { + id: "act-2", + sku: "SIM-ACTIVATION-PREMIUM", + name: "SIM Premium Activation", + description: "Premium activation", + monthlyPrice: 0, + oneTimePrice: 5000, + catalogMetadata: { + autoAdd: false, + isDefault: false, + }, +} as unknown; + const createService = ({ internet, sim, @@ -122,3 +158,128 @@ describe("CheckoutService - personalized carts", () => { expect(internetCatalogService.getPlansForUser).not.toHaveBeenCalled(); }); }); + +describe("CheckoutService - SIM activation fees", () => { + const internetCatalogService = { + getPlansForUser: jest.fn(), + getPlans: jest.fn(), + getInstallations: jest.fn(), + getAddons: jest.fn(), + }; + + const vpnCatalogService = { + getPlans: jest.fn(), + getActivationFees: jest.fn(), + }; + + it("auto-adds default activation fee when none specified", async () => { + const simCatalogService = { + getPlans: jest.fn().mockResolvedValue([simPlan]), + getPlansForUser: jest.fn(), + getAddons: jest.fn().mockResolvedValue([]), + getActivationFees: jest.fn().mockResolvedValue([defaultActivationFee]), + }; + + const service = createService({ + internet: internetCatalogService, + sim: simCatalogService, + vpn: vpnCatalogService, + }); + + const cart = await service.buildCart(ORDER_TYPE.SIM, { planSku: "SIM-PLAN-1" }); + + expect(cart.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ sku: "SIM-PLAN-1" }), + expect.objectContaining({ + sku: "SIM-ACTIVATION-FEE", + itemType: "activation", + autoAdded: true, + }), + ]) + ); + expect(cart.totals.oneTimeTotal).toBe(3000); + }); + + it("respects explicit activation fee selection", async () => { + const simCatalogService = { + getPlans: jest.fn().mockResolvedValue([simPlan]), + getPlansForUser: jest.fn(), + getAddons: jest.fn().mockResolvedValue([]), + getActivationFees: jest + .fn() + .mockResolvedValue([defaultActivationFee, alternateActivationFee]), + }; + + const service = createService({ + internet: internetCatalogService, + sim: simCatalogService, + vpn: vpnCatalogService, + }); + + const cart = await service.buildCart(ORDER_TYPE.SIM, { + planSku: "SIM-PLAN-1", + activationFeeSku: "SIM-ACTIVATION-PREMIUM", + }); + + expect(cart.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sku: "SIM-ACTIVATION-PREMIUM", + autoAdded: false, + }), + ]) + ); + expect(cart.totals.oneTimeTotal).toBe(5000); + }); + + it("throws when no activation fee is available", async () => { + const simCatalogService = { + getPlans: jest.fn().mockResolvedValue([simPlan]), + getPlansForUser: jest.fn(), + getAddons: jest.fn().mockResolvedValue([]), + getActivationFees: jest.fn().mockResolvedValue([]), + }; + + const service = createService({ + internet: internetCatalogService, + sim: simCatalogService, + vpn: vpnCatalogService, + }); + + await expect( + service.buildCart(ORDER_TYPE.SIM, { planSku: "SIM-PLAN-1" }) + ).rejects.toThrow("SIM activation fee is not available"); + }); + + it("skips activation fees without SKUs and falls back to the next valid option", async () => { + const simCatalogService = { + getPlans: jest.fn().mockResolvedValue([simPlan]), + getPlansForUser: jest.fn(), + getAddons: jest.fn().mockResolvedValue([]), + getActivationFees: jest.fn().mockResolvedValue([ + { ...defaultActivationFee, sku: "" }, + alternateActivationFee, + ]), + }; + + const service = createService({ + internet: internetCatalogService, + sim: simCatalogService, + vpn: vpnCatalogService, + }); + + const cart = await service.buildCart(ORDER_TYPE.SIM, { planSku: "SIM-PLAN-1" }); + + expect(cart.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + sku: "SIM-ACTIVATION-PREMIUM", + itemType: "activation", + autoAdded: true, + }), + ]) + ); + expect(cart.totals.oneTimeTotal).toBe(5000); + }); +}); diff --git a/apps/bff/src/modules/orders/services/checkout.service.ts b/apps/bff/src/modules/orders/services/checkout.service.ts index a54f8203..aa945622 100644 --- a/apps/bff/src/modules/orders/services/checkout.service.ts +++ b/apps/bff/src/modules/orders/services/checkout.service.ts @@ -78,6 +78,11 @@ export class CheckoutService { this.logger.log("Checkout cart built successfully", { itemCount: validatedCart.items.length, + items: validatedCart.items.map(item => ({ + sku: item.sku, + name: item.name, + itemType: item.itemType, + })), monthlyTotal: validatedCart.totals.monthlyTotal, oneTimeTotal: validatedCart.totals.oneTimeTotal, }); @@ -225,8 +230,9 @@ export class CheckoutService { const plans: SimCatalogProduct[] = userId ? await this.simCatalogService.getPlansForUser(userId) : await this.simCatalogService.getPlans(); - const activationFees: SimActivationFeeCatalogItem[] = + const rawActivationFees: SimActivationFeeCatalogItem[] = await this.simCatalogService.getActivationFees(); + const activationFees = this.filterActivationFeesWithSku(rawActivationFees); const addons: SimCatalogProduct[] = await this.simCatalogService.getAddons(); // Add main plan @@ -250,27 +256,43 @@ export class CheckoutService { itemType: "plan", }); - // Add activation fee - const simType = selections.simType || "eSIM"; - const activation = activationFees.find(fee => { - const metadata = fee.catalogMetadata as { simType?: string } | undefined; - const feeSimType = metadata?.simType; - return feeSimType ? feeSimType === simType : fee.sku === selections.activationFeeSku; + // Add activation fee (required) + this.logger.debug("Activation fees available", { + rawActivationFeeCount: rawActivationFees.length, + sanitizedActivationFeeCount: activationFees.length, + activationFees: activationFees.map(f => ({ id: f.id, sku: f.sku, name: f.name })), }); - - if (activation) { - items.push({ - id: activation.id, - sku: activation.sku, - name: activation.name, - description: activation.description, - monthlyPrice: activation.monthlyPrice, - oneTimePrice: activation.oneTimePrice, - quantity: 1, - itemType: "activation", + + const activationSelection = this.resolveSimActivationFee(activationFees, selections); + if (!activationSelection) { + this.logger.error("SIM activation fee is not available", { + planSku: selections.planSku, + rawActivationFeeCount: rawActivationFees.length, + sanitizedActivationFeeCount: activationFees.length, }); + throw new BadRequestException("SIM activation fee is not available"); } + const { fee: activation, autoAdded } = activationSelection; + this.logger.debug("Adding activation fee to cart", { + id: activation.id, + sku: activation.sku, + name: activation.name, + autoAdded, + }); + + items.push({ + id: activation.id, + sku: activation.sku, + name: activation.name, + description: activation.description, + monthlyPrice: activation.monthlyPrice, + oneTimePrice: activation.oneTimePrice, + quantity: 1, + itemType: "activation", + autoAdded, + }); + // Add addons const addonRefs = this.collectAddonRefs(selections); for (const ref of addonRefs) { @@ -341,6 +363,57 @@ export class CheckoutService { return { items }; } + /** + * Resolve the correct SIM activation fee to include in the cart + */ + private filterActivationFeesWithSku( + activationFees: SimActivationFeeCatalogItem[] + ): SimActivationFeeCatalogItem[] { + return activationFees.filter(fee => { + const hasSku = typeof fee.sku === "string" && fee.sku.trim().length > 0; + if (!hasSku) { + this.logger.warn("Skipping SIM activation fee without SKU", { + activationId: fee.id, + name: fee.name, + }); + } + return hasSku; + }); + } + + private resolveSimActivationFee( + activationFees: SimActivationFeeCatalogItem[], + selections: OrderSelections + ): { fee: SimActivationFeeCatalogItem; autoAdded: boolean } | null { + if (!Array.isArray(activationFees) || activationFees.length === 0) { + return null; + } + + const findByRef = (ref?: string | null): SimActivationFeeCatalogItem | undefined => { + if (!ref) { + return undefined; + } + return activationFees.find(fee => fee.sku === ref || fee.id === ref); + }; + + const explicitFee = findByRef(selections.activationFeeSku); + if (explicitFee) { + return { fee: explicitFee, autoAdded: false }; + } + + const autoAddFee = activationFees.find(fee => fee.catalogMetadata?.autoAdd); + if (autoAddFee) { + return { fee: autoAddFee, autoAdded: true }; + } + + const defaultFee = activationFees.find(fee => fee.catalogMetadata?.isDefault); + if (defaultFee) { + return { fee: defaultFee, autoAdded: true }; + } + + return { fee: activationFees[0], autoAdded: true }; + } + /** * Collect addon references from selections */ diff --git a/apps/bff/src/modules/orders/services/order-pricebook.service.ts b/apps/bff/src/modules/orders/services/order-pricebook.service.ts index 5dcc7787..e16a61b0 100644 --- a/apps/bff/src/modules/orders/services/order-pricebook.service.ts +++ b/apps/bff/src/modules/orders/services/order-pricebook.service.ts @@ -13,7 +13,7 @@ import { sanitizeSoqlLiteral, } from "@bff/integrations/salesforce/utils/soql.util"; -interface PricebookProductMeta { +export interface PricebookProductMeta { sku: string; pricebookEntryId: string; product2Id?: string; diff --git a/apps/bff/src/modules/orders/services/order-validator.service.ts b/apps/bff/src/modules/orders/services/order-validator.service.ts index 2385a4c4..30972009 100644 --- a/apps/bff/src/modules/orders/services/order-validator.service.ts +++ b/apps/bff/src/modules/orders/services/order-validator.service.ts @@ -13,7 +13,8 @@ import { import type { Providers } from "@customer-portal/domain/subscriptions"; type WhmcsProduct = Providers.WhmcsRaw.WhmcsProductRaw; -import { OrderPricebookService } from "./order-pricebook.service"; +import { SimCatalogService } from "@bff/modules/catalog/services/sim-catalog.service"; +import { OrderPricebookService, type PricebookProductMeta } from "./order-pricebook.service"; import { PaymentValidatorService } from "./payment-validator.service"; /** @@ -29,6 +30,7 @@ export class OrderValidator { private readonly mappings: MappingsService, private readonly whmcs: WhmcsConnectionOrchestratorService, private readonly pricebookService: OrderPricebookService, + private readonly simCatalogService: SimCatalogService, private readonly paymentValidator: PaymentValidatorService ) {} @@ -186,7 +188,10 @@ export class OrderValidator { /** * Validate SKUs exist in Salesforce */ - async validateSKUs(skus: string[], pricebookId: string): Promise { + async validateSKUs( + skus: string[], + pricebookId: string + ): Promise> { const invalidSKUs: string[] = []; const meta = await this.pricebookService.fetchProductMeta(pricebookId, skus); const normalizedSkus = skus @@ -204,6 +209,8 @@ export class OrderValidator { this.logger.error({ invalidSKUs }, "Invalid SKUs found in order"); throw new BadRequestException(`Invalid products: ${invalidSKUs.join(", ")}`); } + + return meta; } /** @@ -236,6 +243,14 @@ export class OrderValidator { const path = issue.path.join("."); return path ? `${path}: ${issue.message}` : issue.message; }); + + this.logger.error("Business validation failed", { + userId, + orderType: validatedBody.orderType, + skus: validatedBody.skus, + validationErrors: issues, + }); + throw new BadRequestException({ message: "Order business validation failed", errors: issues, @@ -251,7 +266,34 @@ export class OrderValidator { // 3. SKU validation const pricebookId = await this.pricebookService.findPortalPricebookId(); - await this.validateSKUs(businessValidatedBody.skus, pricebookId); + const productMeta = await this.validateSKUs(businessValidatedBody.skus, pricebookId); + + if (businessValidatedBody.orderType === "SIM") { + const activationFees = await this.simCatalogService.getActivationFees(); + const activationSkus = new Set( + activationFees + .map(fee => fee.sku?.trim()) + .filter((sku): sku is string => Boolean(sku)) + .map(sku => sku.toUpperCase()) + ); + const hasActivationSku = businessValidatedBody.skus.some(sku => { + const normalized = sku?.trim().toUpperCase(); + if (!normalized) { + return false; + } + return activationSkus.has(normalized); + }); + + if (!hasActivationSku) { + this.logger.warn( + { + skus: businessValidatedBody.skus, + }, + "SIM order missing activation SKU based on catalog metadata" + ); + throw new BadRequestException("SIM orders require an activation fee"); + } + } // 4. Order-specific business validation if (businessValidatedBody.orderType === "Internet") { diff --git a/apps/bff/src/modules/subscriptions/sim-management/queue/sim-management.processor.ts b/apps/bff/src/modules/subscriptions/sim-management/queue/sim-management.processor.ts new file mode 100644 index 00000000..45c0accd --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/queue/sim-management.processor.ts @@ -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): Promise { + 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 { + 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; + } + } +} + diff --git a/apps/bff/src/modules/subscriptions/sim-management/queue/sim-management.queue.ts b/apps/bff/src/modules/subscriptions/sim-management/queue/sim-management.queue.ts new file mode 100644 index 00000000..a43039d3 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/queue/sim-management.queue.ts @@ -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, + @Inject(Logger) private readonly logger: Logger + ) {} + + async scheduleNetworkTypeChange( + data: NetworkTypeChangeJob, + delayMs = 30 * 60 * 1000 + ): Promise { + 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, + }); + } +} + diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-action-runner.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-action-runner.service.ts new file mode 100644 index 00000000..4fe8c8a7 --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-action-runner.service.ts @@ -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 { + baseContext: SimNotificationContext; + enrichSuccess?: (result: T) => Partial; + enrichError?: (error: unknown) => Partial; +} + +@Injectable() +export class SimActionRunnerService { + constructor(private readonly simNotification: SimNotificationService) {} + + async run( + action: string, + options: RunOptions, + handler: () => Promise + ): Promise { + 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; + } + } +} + diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts new file mode 100644 index 00000000..e6beee8c --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts @@ -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; +} + +@Injectable() +export class SimBillingService { + constructor(private readonly whmcs: WhmcsService, @Inject(Logger) private readonly logger: Logger) {} + + async createOneTimeCharge(params: OneTimeChargeParams): Promise { + 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 { + await this.whmcs.updateInvoice({ + invoiceId, + notes: note, + }); + } + + private async cancelInvoice(invoiceId: number, reason: string): Promise { + 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), + }); + } + } +} + diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts index 865021b2..5e5daf80 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-cancellation.service.ts @@ -2,16 +2,18 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service"; import { SimValidationService } from "./sim-validation.service"; -import { SimNotificationService } from "./sim-notification.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import type { SimCancelRequest } from "@customer-portal/domain/sim"; +import { SimScheduleService } from "./sim-schedule.service"; +import { SimActionRunnerService } from "./sim-action-runner.service"; @Injectable() export class SimCancellationService { constructor( private readonly freebitService: FreebitOrchestratorService, private readonly simValidation: SimValidationService, - private readonly simNotification: SimNotificationService, + private readonly simSchedule: SimScheduleService, + private readonly simActionRunner: SimActionRunnerService, @Inject(Logger) private readonly logger: Logger ) {} @@ -23,52 +25,44 @@ export class SimCancellationService { subscriptionId: number, request: SimCancelRequest = {} ): Promise { - try { - const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId); + let account = ""; - // Determine run date (PA02-04 requires runDate); default to 1st of next month - let runDate = request.scheduledAt; - if (runDate && !/^\d{8}$/.test(runDate)) { - throw new BadRequestException("Scheduled date must be in YYYYMMDD format"); + await this.simActionRunner.run( + "Cancel SIM", + { + baseContext: { + userId, + subscriptionId, + scheduledAt: request.scheduledAt, + }, + enrichSuccess: result => ({ + account: result.account, + runDate: result.runDate, + }), + enrichError: () => ({ + account, + }), + }, + async () => { + const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId); + account = validation.account; + + const scheduleResolution = this.simSchedule.resolveScheduledDate(request.scheduledAt); + + await this.freebitService.cancelSim(account, scheduleResolution.date); + + this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, { + userId, + subscriptionId, + account, + runDate: scheduleResolution.date, + }); + + return { + account, + runDate: scheduleResolution.date, + }; } - if (!runDate) { - const nextMonth = new Date(); - nextMonth.setMonth(nextMonth.getMonth() + 1); - nextMonth.setDate(1); - const y = nextMonth.getFullYear(); - const m = String(nextMonth.getMonth() + 1).padStart(2, "0"); - const d = String(nextMonth.getDate()).padStart(2, "0"); - runDate = `${y}${m}${d}`; - } - - await this.freebitService.cancelSim(account, runDate); - - this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, { - userId, - subscriptionId, - account, - runDate, - }); - - await this.simNotification.notifySimAction("Cancel SIM", "SUCCESS", { - userId, - subscriptionId, - account, - runDate, - }); - } catch (error) { - const sanitizedError = getErrorMessage(error); - this.logger.error(`Failed to cancel SIM for subscription ${subscriptionId}`, { - error: sanitizedError, - userId, - subscriptionId, - }); - await this.simNotification.notifySimAction("Cancel SIM", "ERROR", { - userId, - subscriptionId, - error: sanitizedError, - }); - throw error; - } + ); } } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts index b9d9ad4f..569e675c 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-plan.service.ts @@ -2,16 +2,20 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service"; import { SimValidationService } from "./sim-validation.service"; -import { SimNotificationService } from "./sim-notification.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import type { SimPlanChangeRequest, SimFeaturesUpdateRequest } from "@customer-portal/domain/sim"; +import { SimScheduleService } from "./sim-schedule.service"; +import { SimActionRunnerService } from "./sim-action-runner.service"; +import { SimManagementQueueService } from "../queue/sim-management.queue"; @Injectable() export class SimPlanService { constructor( private readonly freebitService: FreebitOrchestratorService, private readonly simValidation: SimValidationService, - private readonly simNotification: SimNotificationService, + private readonly simSchedule: SimScheduleService, + private readonly simActionRunner: SimActionRunnerService, + private readonly simQueue: SimManagementQueueService, @Inject(Logger) private readonly logger: Logger ) {} @@ -23,91 +27,72 @@ export class SimPlanService { subscriptionId: number, request: SimPlanChangeRequest ): Promise<{ ipv4?: string; ipv6?: string }> { + let account = ""; const assignGlobalIp = request.assignGlobalIp ?? false; - let scheduledAt: string | undefined; - try { - const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId); + const response = await this.simActionRunner.run( + "Change Plan", + { + baseContext: { + userId, + subscriptionId, + newPlanCode: request.newPlanCode, + assignGlobalIp, + }, + enrichSuccess: result => ({ + account: result.account, + scheduledAt: result.scheduledAt, + }), + enrichError: () => ({ + account, + }), + }, + async () => { + const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId); + account = validation.account; - // Validate plan code format - if (!request.newPlanCode || request.newPlanCode.length < 3) { - throw new BadRequestException("Invalid plan code"); - } - - scheduledAt = request.scheduledAt; - if (scheduledAt) { - if (!/^\d{8}$/.test(scheduledAt)) { - throw new BadRequestException("scheduledAt must be in YYYYMMDD format"); + if (!request.newPlanCode || request.newPlanCode.length < 3) { + throw new BadRequestException("Invalid plan code"); } - } else { - // Default to the 1st of the next month if no schedule provided. - const nextMonth = new Date(); - nextMonth.setMonth(nextMonth.getMonth() + 1); - nextMonth.setDate(1); - const year = nextMonth.getFullYear(); - const month = String(nextMonth.getMonth() + 1).padStart(2, "0"); - const day = String(nextMonth.getDate()).padStart(2, "0"); - scheduledAt = `${year}${month}${day}`; + + const scheduleResolution = this.simSchedule.resolveScheduledDate(request.scheduledAt); + + this.logger.log("Submitting SIM plan change request", { + userId, + subscriptionId, + account, + newPlanCode: request.newPlanCode, + scheduledAt: scheduleResolution.date, + assignGlobalIp, + scheduleOrigin: scheduleResolution.source, + }); + + const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, { + assignGlobalIp, + scheduledAt: scheduleResolution.date, + }); + + this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, { + userId, + subscriptionId, + account, + newPlanCode: request.newPlanCode, + scheduledAt: scheduleResolution.date, + assignGlobalIp, + }); + + return { + ...result, + account, + scheduledAt: scheduleResolution.date, + }; } + ); - this.logger.log("Submitting SIM plan change request", { - userId, - subscriptionId, - account, - newPlanCode: request.newPlanCode, - scheduledAt, - assignGlobalIp, - scheduleOrigin: request.scheduledAt ? "user-provided" : "auto-default", - }); - - if (!scheduledAt) { - throw new BadRequestException("Failed to determine schedule date for plan change"); - } - - const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, { - assignGlobalIp, - scheduledAt, - }); - - this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, { - userId, - subscriptionId, - account, - newPlanCode: request.newPlanCode, - scheduledAt, - assignGlobalIp, - }); - - await this.simNotification.notifySimAction("Change Plan", "SUCCESS", { - userId, - subscriptionId, - account, - newPlanCode: request.newPlanCode, - scheduledAt, - assignGlobalIp, - }); - - return result; - } catch (error) { - const sanitizedError = getErrorMessage(error); - this.logger.error(`Failed to change SIM plan for subscription ${subscriptionId}`, { - error: sanitizedError, - userId, - subscriptionId, - newPlanCode: request.newPlanCode, - assignGlobalIp, - scheduledAt, - }); - await this.simNotification.notifySimAction("Change Plan", "ERROR", { - userId, - subscriptionId, - newPlanCode: request.newPlanCode, - assignGlobalIp, - scheduledAt, - error: sanitizedError, - }); - throw error; - } + return { + ipv4: response.ipv4, + ipv6: response.ipv6, + }; } /** @@ -118,93 +103,68 @@ export class SimPlanService { subscriptionId: number, request: SimFeaturesUpdateRequest ): Promise { - try { - const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId); + let account = ""; - // Validate network type if provided - if (request.networkType && !["4G", "5G"].includes(request.networkType)) { - throw new BadRequestException('networkType must be either "4G" or "5G"'); - } + await this.simActionRunner.run( + "Update Features", + { + baseContext: { + userId, + subscriptionId, + ...request, + }, + enrichSuccess: () => ({ + account, + }), + enrichError: () => ({ + account, + }), + }, + async () => { + const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId); + account = validation.account; - const doVoice = - typeof request.voiceMailEnabled === "boolean" || - typeof request.callWaitingEnabled === "boolean" || - typeof request.internationalRoamingEnabled === "boolean"; - const doContract = typeof request.networkType === "string"; + if (request.networkType && !["4G", "5G"].includes(request.networkType)) { + throw new BadRequestException('networkType must be either "4G" or "5G"'); + } - if (doVoice && doContract) { - // First apply voice options immediately (PA05-06) - await this.freebitService.updateSimFeatures(account, { - voiceMailEnabled: request.voiceMailEnabled, - callWaitingEnabled: request.callWaitingEnabled, - internationalRoamingEnabled: request.internationalRoamingEnabled, - }); + const doVoice = + typeof request.voiceMailEnabled === "boolean" || + typeof request.callWaitingEnabled === "boolean" || + typeof request.internationalRoamingEnabled === "boolean"; + const doContract = typeof request.networkType === "string"; - // Then schedule contract line change after 30 minutes (PA05-38) - const delayMs = 30 * 60 * 1000; - setTimeout(() => { - this.freebitService - .updateSimFeatures(account, { networkType: request.networkType }) - .then(() => - this.logger.log("Deferred contract line change executed after 30 minutes", { - userId, - subscriptionId, - account, - networkType: request.networkType, - }) - ) - .catch(err => - this.logger.error("Deferred contract line change failed", { - error: getErrorMessage(err), - userId, - subscriptionId, - account, - }) - ); - }, delayMs); + if (doVoice && doContract) { + await this.freebitService.updateSimFeatures(account, { + voiceMailEnabled: request.voiceMailEnabled, + callWaitingEnabled: request.callWaitingEnabled, + internationalRoamingEnabled: request.internationalRoamingEnabled, + }); - this.logger.log("Scheduled contract line change 30 minutes after voice option change", { + await this.simQueue.scheduleNetworkTypeChange({ + account, + networkType: request.networkType as "4G" | "5G", + userId, + subscriptionId, + }); + + this.logger.log("Scheduled contract line change via queue after voice option change", { + userId, + subscriptionId, + account, + networkType: request.networkType, + }); + } else { + await this.freebitService.updateSimFeatures(account, request); + } + + this.logger.log(`Updated SIM features for subscription ${subscriptionId}`, { userId, subscriptionId, account, - networkType: request.networkType, + ...request, }); - } else { - await this.freebitService.updateSimFeatures(account, request); } - - this.logger.log(`Updated SIM features for subscription ${subscriptionId}`, { - userId, - subscriptionId, - account, - ...request, - }); - - await this.simNotification.notifySimAction("Update Features", "SUCCESS", { - userId, - subscriptionId, - account, - ...request, - note: - doVoice && doContract - ? "Voice options applied immediately; contract line change scheduled after 30 minutes" - : undefined, - }); - } catch (error) { - const sanitizedError = getErrorMessage(error); - this.logger.error(`Failed to update SIM features for subscription ${subscriptionId}`, { - error: sanitizedError, - userId, - subscriptionId, - ...request, - }); - await this.simNotification.notifySimAction("Update Features", "ERROR", { - userId, - subscriptionId, - ...request, - error: sanitizedError, - }); - throw error; - } + ); } } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-schedule.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-schedule.service.ts new file mode 100644 index 00000000..a649a40d --- /dev/null +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-schedule.service.ts @@ -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]; + } +} + diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts index 13ef40c5..ed5d5eea 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts @@ -1,21 +1,21 @@ import { Injectable, Inject, BadRequestException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service"; -import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service"; import { SimValidationService } from "./sim-validation.service"; -import { SimNotificationService } from "./sim-notification.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import type { SimTopUpRequest } from "@customer-portal/domain/sim"; +import { SimBillingService } from "./sim-billing.service"; +import { SimActionRunnerService } from "./sim-action-runner.service"; @Injectable() export class SimTopUpService { constructor( private readonly freebitService: FreebitOrchestratorService, - private readonly whmcsService: WhmcsService, private readonly mappingsService: MappingsService, private readonly simValidation: SimValidationService, - private readonly simNotification: SimNotificationService, + private readonly simBilling: SimBillingService, + private readonly simActionRunner: SimActionRunnerService, @Inject(Logger) private readonly logger: Logger ) {} @@ -24,171 +24,102 @@ export class SimTopUpService { * Pricing: 1GB = 500 JPY */ async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise { - let account: string = ""; + let latestAccount = ""; - try { - const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId); - account = validation.account; - - // Validate quota amount - if (request.quotaMb <= 0 || request.quotaMb > 100000) { - throw new BadRequestException("Quota must be between 1MB and 100GB"); - } - - // Calculate cost: 1GB = 500 JPY (rounded up to nearest GB) - const quotaGb = request.quotaMb / 1000; - const units = Math.ceil(quotaGb); - const costJpy = units * 500; - - // Validate quota against Freebit API limits (100MB - 51200MB) - if (request.quotaMb < 100 || request.quotaMb > 51200) { - throw new BadRequestException( - "Quota must be between 100MB and 51200MB (50GB) for Freebit API compatibility" - ); - } - - // Get client mapping for WHMCS - const mapping = await this.mappingsService.findByUserId(userId); - if (!mapping?.whmcsClientId) { - throw new BadRequestException("WHMCS client mapping not found"); - } - - const whmcsClientId = mapping.whmcsClientId; - - this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, { - userId, - subscriptionId, - account, - quotaMb: request.quotaMb, - quotaGb: quotaGb.toFixed(2), - costJpy, - }); - - // Step 1: Create WHMCS invoice - const invoice = await this.whmcsService.createInvoice({ - clientId: whmcsClientId, - description: `SIM Data Top-up: ${units}GB for ${account}`, - amount: costJpy, - currency: "JPY", - dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now - notes: `Subscription ID: ${subscriptionId}, Phone: ${account}`, - }); - - this.logger.log(`Created WHMCS invoice ${invoice.id} for SIM top-up`, { - invoiceId: invoice.id, - invoiceNumber: invoice.number, - amount: costJpy, - subscriptionId, - }); - - // Step 2: Capture payment - this.logger.log(`Attempting payment capture`, { - invoiceId: invoice.id, - amount: costJpy, - }); - - const paymentResult = await this.whmcsService.capturePayment({ - invoiceId: invoice.id, - amount: costJpy, - currency: "JPY", - }); - - if (!paymentResult.success) { - this.logger.error(`Payment capture failed for invoice ${invoice.id}`, { - invoiceId: invoice.id, - error: paymentResult.error, + await this.simActionRunner.run( + "Top Up Data", + { + baseContext: { + userId, subscriptionId, + quotaMb: request.quotaMb, + }, + enrichSuccess: meta => ({ + account: meta.account, + costJpy: meta.costJpy, + invoiceId: meta.invoiceId, + transactionId: meta.transactionId, + }), + enrichError: () => ({ + account: latestAccount, + }), + }, + async () => { + const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId); + latestAccount = validation.account; + + if (request.quotaMb <= 0 || request.quotaMb > 100000) { + throw new BadRequestException("Quota must be between 1MB and 100GB"); + } + + const quotaGb = request.quotaMb / 1000; + const units = Math.ceil(quotaGb); + const costJpy = units * 500; + + if (request.quotaMb < 100 || request.quotaMb > 51200) { + throw new BadRequestException( + "Quota must be between 100MB and 51200MB (50GB) for Freebit API compatibility" + ); + } + + const mapping = await this.mappingsService.findByUserId(userId); + if (!mapping?.whmcsClientId) { + throw new BadRequestException("WHMCS client mapping not found"); + } + + this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, { + userId, + subscriptionId, + account: latestAccount, + quotaMb: request.quotaMb, + quotaGb: quotaGb.toFixed(2), + costJpy, }); - // Cancel the invoice since payment failed - await this.handlePaymentFailure(invoice.id, paymentResult.error || "Unknown payment error"); + const billing = await this.simBilling.createOneTimeCharge({ + clientId: mapping.whmcsClientId, + description: `SIM Data Top-up: ${units}GB for ${latestAccount}`, + amountJpy: costJpy, + currency: "JPY", + dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + notes: `Subscription ID: ${subscriptionId}, Phone: ${latestAccount}`, + failureNotesPrefix: "Payment capture failed", + publicErrorMessage: "SIM top-up failed: payment could not be processed", + metadata: { subscriptionId }, + }); - throw new BadRequestException(`SIM top-up failed: ${paymentResult.error}`); - } - - this.logger.log(`Payment captured successfully for invoice ${invoice.id}`, { - invoiceId: invoice.id, - transactionId: paymentResult.transactionId, - amount: costJpy, - subscriptionId, - }); - - try { - // Step 3: Only if payment successful, add data via Freebit - await this.freebitService.topUpSim(account, request.quotaMb, {}); + try { + await this.freebitService.topUpSim(latestAccount, request.quotaMb, {}); + } catch (freebitError) { + await this.handleFreebitFailureAfterPayment( + freebitError, + billing.invoice, + billing.transactionId || "unknown", + userId, + subscriptionId, + latestAccount, + request.quotaMb + ); + } this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, { userId, subscriptionId, - account, + account: latestAccount, quotaMb: request.quotaMb, costJpy, - invoiceId: invoice.id, - transactionId: paymentResult.transactionId, + invoiceId: billing.invoice.id, + transactionId: billing.transactionId, }); - await this.simNotification.notifySimAction("Top Up Data", "SUCCESS", { - userId, - subscriptionId, - account, - quotaMb: request.quotaMb, + return { + account: latestAccount, costJpy, - invoiceId: invoice.id, - transactionId: paymentResult.transactionId, - }); - } catch (freebitError) { - // If Freebit fails after payment, handle carefully - await this.handleFreebitFailureAfterPayment( - freebitError, - invoice, - paymentResult.transactionId || "unknown", - userId, - subscriptionId, - account, - request.quotaMb - ); + invoiceId: billing.invoice.id, + transactionId: billing.transactionId, + }; } - } catch (error) { - const sanitizedError = getErrorMessage(error); - this.logger.error(`Failed to top up SIM for subscription ${subscriptionId}`, { - error: sanitizedError, - userId, - subscriptionId, - quotaMb: request.quotaMb, - }); - await this.simNotification.notifySimAction("Top Up Data", "ERROR", { - userId, - subscriptionId, - account: account ?? "", - quotaMb: request.quotaMb, - error: sanitizedError, - }); - throw error; - } - } - - /** - * Handle payment failure by canceling the invoice - */ - private async handlePaymentFailure(invoiceId: number, error: string): Promise { - try { - await this.whmcsService.updateInvoice({ - invoiceId, - status: "Cancelled", - notes: `Payment capture failed: ${error}. Invoice cancelled automatically.`, - }); - - this.logger.log(`Cancelled invoice ${invoiceId} due to payment failure`, { - invoiceId, - reason: "Payment capture failed", - }); - } catch (cancelError) { - this.logger.error(`Failed to cancel invoice ${invoiceId} after payment failure`, { - invoiceId, - cancelError: getErrorMessage(cancelError), - originalError: error, - }); - } + ); } /** @@ -219,10 +150,12 @@ export class SimTopUpService { // Add a note to the invoice about the Freebit failure try { - await this.whmcsService.updateInvoice({ - invoiceId: invoice.id, - notes: `Payment successful but SIM top-up failed: ${getErrorMessage(freebitError)}. Manual intervention required.`, - }); + await this.simBilling.appendInvoiceNote( + invoice.id, + `Payment successful but SIM top-up failed: ${getErrorMessage( + freebitError + )}. Manual intervention required.` + ); this.logger.log(`Added failure note to invoice ${invoice.id}`, { invoiceId: invoice.id, @@ -241,16 +174,8 @@ export class SimTopUpService { // to ensure consistency across all failure scenarios. // For manual refunds, use the WHMCS admin panel or dedicated refund endpoints. - const errMsg = `Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`; - await this.simNotification.notifySimAction("Top Up Data", "ERROR", { - userId, - subscriptionId, - account, - quotaMb, - invoiceId: invoice.id, - transactionId, - error: getErrorMessage(freebitError), - }); - throw new Error(errMsg); + throw new Error( + `Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.` + ); } } diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts index a93c3f7d..be99eec2 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-usage.service.ts @@ -6,7 +6,7 @@ import { SimUsageStoreService } from "../../sim-usage-store.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import type { SimTopUpHistory, SimUsage } from "@customer-portal/domain/sim"; import type { SimTopUpHistoryRequest } from "@customer-portal/domain/sim"; -import { BadRequestException } from "@nestjs/common"; +import { SimScheduleService } from "./sim-schedule.service"; @Injectable() export class SimUsageService { @@ -14,6 +14,7 @@ export class SimUsageService { private readonly freebitService: FreebitOrchestratorService, private readonly simValidation: SimValidationService, private readonly usageStore: SimUsageStoreService, + private readonly simSchedule: SimScheduleService, @Inject(Logger) private readonly logger: Logger ) {} @@ -76,15 +77,13 @@ export class SimUsageService { try { const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId); - // Validate date format - if (!/^\d{8}$/.test(request.fromDate) || !/^\d{8}$/.test(request.toDate)) { - throw new BadRequestException("Dates must be in YYYYMMDD format"); - } + const fromDate = this.simSchedule.ensureYyyyMmDd(request.fromDate, "fromDate"); + const toDate = this.simSchedule.ensureYyyyMmDd(request.toDate, "toDate"); const history = await this.freebitService.getSimTopUpHistory( account, - request.fromDate, - request.toDate + fromDate, + toDate ); this.logger.log(`Retrieved SIM top-up history for subscription ${subscriptionId}`, { diff --git a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts index 1686d57d..b402adc6 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts @@ -16,6 +16,11 @@ import { SimCancellationService } from "./services/sim-cancellation.service"; import { EsimManagementService } from "./services/esim-management.service"; import { SimValidationService } from "./services/sim-validation.service"; import { SimNotificationService } from "./services/sim-notification.service"; +import { SimBillingService } from "./services/sim-billing.service"; +import { SimScheduleService } from "./services/sim-schedule.service"; +import { SimActionRunnerService } from "./services/sim-action-runner.service"; +import { SimManagementQueueService } from "./queue/sim-management.queue"; +import { SimManagementProcessor } from "./queue/sim-management.processor"; @Module({ imports: [FreebitModule, WhmcsModule, MappingsModule, EmailModule], @@ -34,6 +39,11 @@ import { SimNotificationService } from "./services/sim-notification.service"; SimCancellationService, EsimManagementService, SimOrchestratorService, + SimBillingService, + SimScheduleService, + SimActionRunnerService, + SimManagementQueueService, + SimManagementProcessor, ], exports: [ SimOrchestratorService, @@ -46,6 +56,10 @@ import { SimNotificationService } from "./services/sim-notification.service"; EsimManagementService, SimValidationService, SimNotificationService, + SimBillingService, + SimScheduleService, + SimActionRunnerService, + SimManagementQueueService, ], }) export class SimManagementModule {} diff --git a/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts b/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts index 7d8fb9a2..93992dea 100644 --- a/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts @@ -7,6 +7,8 @@ import { CacheService } from "@bff/infra/cache/cache.service"; import { getErrorMessage } from "@bff/core/utils/error.util"; import type { SimOrderActivationRequest } from "@customer-portal/domain/sim"; import { randomUUID } from "crypto"; +import { SimBillingService } from "./sim-management/services/sim-billing.service"; +import { SimScheduleService } from "./sim-management/services/sim-schedule.service"; @Injectable() export class SimOrderActivationService { @@ -15,6 +17,8 @@ export class SimOrderActivationService { private readonly whmcs: WhmcsService, private readonly mappings: MappingsService, private readonly cache: CacheService, + private readonly simBilling: SimBillingService, + private readonly simSchedule: SimScheduleService, @Inject(Logger) private readonly logger: Logger ) {} @@ -51,69 +55,82 @@ export class SimOrderActivationService { // Mark as processing (5 minute TTL) await this.cache.set(processingKey, true, 300); + + const releaseProcessingFlag = async () => { + await this.cache.del(processingKey); + }; + if (req.simType === "eSIM" && (!req.eid || req.eid.length < 15)) { + await releaseProcessingFlag(); throw new BadRequestException("EID is required for eSIM and must be valid"); } if (!req.msisdn || req.msisdn.trim() === "") { + await releaseProcessingFlag(); throw new BadRequestException("Phone number (msisdn) is required for SIM activation"); } - if (!/^\d{8}$/.test(req.scheduledAt || "") && req.activationType === "Scheduled") { - throw new BadRequestException("scheduledAt must be YYYYMMDD when scheduling activation"); + try { + if (req.activationType === "Scheduled") { + this.simSchedule.ensureYyyyMmDd(req.scheduledAt, "scheduledAt"); + } else { + this.simSchedule.validateOptionalYyyyMmDd(req.scheduledAt, "scheduledAt"); + } + } catch (error) { + await releaseProcessingFlag(); + throw error; + } + + if (req.simType !== "eSIM") { + this.logger.warn("Physical SIM activation blocked", { + userId, + simType: req.simType, + msisdn: req.msisdn, + }); + await releaseProcessingFlag(); + throw new BadRequestException( + "Physical SIM activations are temporarily disabled. Please choose eSIM." + ); } const mapping = await this.mappings.findByUserId(userId); if (!mapping?.whmcsClientId) { + await releaseProcessingFlag(); throw new BadRequestException("WHMCS client mapping not found"); } - // 1) Create invoice for one-time activation fee only - const invoice = await this.whmcs.createInvoice({ - clientId: mapping.whmcsClientId, - description: `SIM Activation Fee (${req.planSku}) for ${req.msisdn}`, - amount: req.oneTimeAmountJpy, - currency: "JPY", - dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), - notes: `SIM activation fee for ${req.msisdn}, plan ${req.planSku}. Monthly billing will start on the 1st of next month.`, - }); + let billingResult: + | { + invoice: { id: number; number: string }; + transactionId?: string; + } + | undefined; - const paymentResult = await this.whmcs.capturePayment({ - invoiceId: invoice.id, - amount: req.oneTimeAmountJpy, - currency: "JPY", - }); - - if (!paymentResult.success) { - await this.whmcs.updateInvoice({ - invoiceId: invoice.id, - status: "Cancelled", - notes: `Payment failed: ${paymentResult.error || "unknown"}`, - }); - throw new BadRequestException(`Payment failed: ${paymentResult.error || "unknown"}`); - } - - // 2) Freebit activation try { - if (req.simType === "eSIM") { - await this.freebit.activateEsimAccountNew({ - account: req.msisdn, - eid: req.eid!, - planCode: req.planSku, - contractLine: "5G", - shipDate: req.activationType === "Scheduled" ? req.scheduledAt : undefined, - mnp: req.mnp - ? { - reserveNumber: req.mnp.reserveNumber || "", - reserveExpireDate: req.mnp.reserveExpireDate || "", - } - : undefined, - }); - } else { - this.logger.warn("Physical SIM activation path is not implemented; skipping Freebit call", { - account: req.msisdn, - }); - } + billingResult = await this.simBilling.createOneTimeCharge({ + clientId: mapping.whmcsClientId, + description: `SIM Activation Fee (${req.planSku}) for ${req.msisdn}`, + amountJpy: req.oneTimeAmountJpy, + currency: "JPY", + dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), + notes: `SIM activation fee for ${req.msisdn}, plan ${req.planSku}. Monthly billing will start on the 1st of next month.`, + failureNotesPrefix: "SIM activation payment failed", + publicErrorMessage: "Unable to process SIM activation payment. Please update your payment method.", + metadata: { userId, msisdn: req.msisdn }, + }); + + await this.freebit.activateEsimAccountNew({ + account: req.msisdn, + eid: req.eid!, + planCode: req.planSku, + contractLine: "5G", + shipDate: req.activationType === "Scheduled" ? req.scheduledAt : undefined, + mnp: req.mnp + ? { + reserveNumber: req.mnp.reserveNumber || "", + reserveExpireDate: req.mnp.reserveExpireDate || "", + } + : undefined, + }); - // 3) Add-ons (voice options) immediately after activation if selected if (req.addons && (req.addons.voiceMail || req.addons.callWaiting)) { await this.freebit.updateSimFeatures(req.msisdn, { voiceMailEnabled: !!req.addons.voiceMail, @@ -121,25 +138,19 @@ export class SimOrderActivationService { }); } - // 4) Create monthly subscription for recurring billing if (req.monthlyAmountJpy > 0) { - const nextMonth = new Date(); - nextMonth.setMonth(nextMonth.getMonth() + 1); - nextMonth.setDate(1); // First day of next month - nextMonth.setHours(0, 0, 0, 0); - - // Create a monthly subscription order using the order service + const nextBillingIso = this.simSchedule.firstDayOfNextMonthIsoDate(); const orderService = this.whmcs.getOrderService(); await orderService.addOrder({ clientId: mapping.whmcsClientId, items: [ { - productId: req.planSku, // Use the plan SKU as product ID + productId: req.planSku, billingCycle: "monthly", quantity: 1, configOptions: { phone_number: req.msisdn, - activation_date: nextMonth.toISOString().split("T")[0], + activation_date: nextBillingIso, }, customFields: { sim_type: req.simType, @@ -147,43 +158,42 @@ export class SimOrderActivationService { }, }, ], - paymentMethod: "mailin", // Default payment method - notes: `Monthly SIM plan billing for ${req.msisdn}, plan ${req.planSku}. Billing starts on the 1st of next month.`, - noinvoice: false, // Create invoice - noinvoiceemail: true, // Suppress invoice email for now - noemail: true, // Suppress order emails + paymentMethod: "mailin", + notes: `Monthly SIM plan billing for ${req.msisdn}, plan ${req.planSku}. Billing starts on ${nextBillingIso}.`, + noinvoice: false, + noinvoiceemail: true, + noemail: true, }); this.logger.log("Monthly subscription created", { account: req.msisdn, amount: req.monthlyAmountJpy, - nextDueDate: nextMonth.toISOString().split("T")[0], + nextDueDate: nextBillingIso, }); } - this.logger.log("SIM activation completed", { account: req.msisdn, invoiceId: invoice.id }); + this.logger.log("SIM activation completed", { + account: req.msisdn, + invoiceId: billingResult.invoice.id, + }); const result = { success: true, - invoiceId: invoice.id, - transactionId: paymentResult.transactionId, + invoiceId: billingResult.invoice.id, + transactionId: billingResult.transactionId, }; - // Cache successful result for 24 hours await this.cache.set(cacheKey, result, 86400); - - // Remove processing flag - await this.cache.del(processingKey); - + await releaseProcessingFlag(); return result; } catch (err) { - // Remove processing flag on error - await this.cache.del(processingKey); - - await this.whmcs.updateInvoice({ - invoiceId: invoice.id, - notes: `Freebit activation failed after payment: ${getErrorMessage(err)}`, - }); + await releaseProcessingFlag(); + if (billingResult?.invoice) { + await this.simBilling.appendInvoiceNote( + billingResult.invoice.id, + `Freebit activation failed after payment: ${getErrorMessage(err)}` + ); + } throw err; } } diff --git a/apps/portal/src/components/organisms/AppShell/Sidebar.tsx b/apps/portal/src/components/organisms/AppShell/Sidebar.tsx index 2b2ab08b..bd522d3d 100644 --- a/apps/portal/src/components/organisms/AppShell/Sidebar.tsx +++ b/apps/portal/src/components/organisms/AppShell/Sidebar.tsx @@ -136,7 +136,7 @@ const NavigationItem = memo(function NavigationItem({ const isChildActive = pathname === (child.href || "").split(/[?#]/)[0]; return ( { diff --git a/apps/portal/src/features/catalog/components/sim/ActivationForm.tsx b/apps/portal/src/features/catalog/components/sim/ActivationForm.tsx index 0d28132b..493e2347 100644 --- a/apps/portal/src/features/catalog/components/sim/ActivationForm.tsx +++ b/apps/portal/src/features/catalog/components/sim/ActivationForm.tsx @@ -1,100 +1,129 @@ +"use client"; + +import { CardPricing } from "@/features/catalog/components/base/CardPricing"; + +interface ActivationFeeDetails { + amount: number; + name: string; +} + interface ActivationFormProps { activationType: "Immediate" | "Scheduled"; onActivationTypeChange: (type: "Immediate" | "Scheduled") => void; scheduledActivationDate: string; onScheduledActivationDateChange: (date: string) => void; errors: Record; + activationFee?: ActivationFeeDetails; } +type ActivationOption = { + type: "Immediate" | "Scheduled"; + title: string; + description: string; +}; + +const activationOptions: ActivationOption[] = [ + { + type: "Immediate", + title: "Immediate Activation", + description: "Activate as soon as your SIM arrives and is set up.", + }, + { + type: "Scheduled", + title: "Scheduled Activation", + description: "Pick a go-live date within the next 30 days.", + }, +]; + export function ActivationForm({ activationType, onActivationTypeChange, scheduledActivationDate, onScheduledActivationDateChange, errors, + activationFee, }: ActivationFormProps) { - const sharedLabelClasses = - "flex items-start gap-3 p-4 rounded-lg border-2 cursor-pointer transition-colors duration-200 ease-in-out"; - return ( -
- - -