import { Injectable, BadRequestException, Inject, ConflictException } 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 { 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"; @Injectable() export class SimOrderActivationService { constructor( private readonly freebit: FreebitOrchestratorService, private readonly whmcs: WhmcsService, private readonly mappings: MappingsService, private readonly cache: CacheService, @Inject(Logger) private readonly logger: Logger ) {} async activate( userId: string, req: SimOrderActivationRequest, idempotencyKey?: string ): Promise<{ success: boolean; invoiceId: number; transactionId?: string }> { // Generate idempotency key if not provided const idemKey = idempotencyKey || randomUUID(); const cacheKey = `sim-activation:${userId}:${idemKey}`; // Check if already processed const existing = await this.cache.get<{ success: boolean; invoiceId: number; transactionId?: string; }>(cacheKey); if (existing) { this.logger.log("Returning cached SIM activation result (idempotent)", { userId, idempotencyKey: idemKey, msisdn: req.msisdn, }); return existing; } // Check if operation is currently processing const processingKey = `${cacheKey}:processing`; const isProcessing = await this.cache.exists(processingKey); if (isProcessing) { throw new ConflictException("SIM activation already in progress. Please wait and retry."); } // Mark as processing (5 minute TTL) await this.cache.set(processingKey, true, 300); if (req.simType === "eSIM" && (!req.eid || req.eid.length < 15)) { throw new BadRequestException("EID is required for eSIM and must be valid"); } if (!req.msisdn || req.msisdn.trim() === "") { 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"); } const mapping = await this.mappings.findByUserId(userId); if (!mapping?.whmcsClientId) { 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.`, }); 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, }); } // 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, callWaitingEnabled: !!req.addons.callWaiting, }); } // 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 orderService = this.whmcs.getOrderService(); await orderService.addOrder({ clientId: mapping.whmcsClientId, items: [ { productId: req.planSku, // Use the plan SKU as product ID billingCycle: "monthly", quantity: 1, configOptions: { phone_number: req.msisdn, activation_date: nextMonth.toISOString().split("T")[0], }, customFields: { sim_type: req.simType, eid: req.eid || "", }, }, ], 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 }); this.logger.log("Monthly subscription created", { account: req.msisdn, amount: req.monthlyAmountJpy, nextDueDate: nextMonth.toISOString().split("T")[0], }); } this.logger.log("SIM activation completed", { account: req.msisdn, invoiceId: invoice.id }); const result = { success: true, invoiceId: invoice.id, transactionId: paymentResult.transactionId, }; // Cache successful result for 24 hours await this.cache.set(cacheKey, result, 86400); // Remove processing flag await this.cache.del(processingKey); 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)}`, }); throw err; } } }