import { Injectable, BadRequestException, Inject, ConflictException } from "@nestjs/common"; import { Logger } from "nestjs-pino"; import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js"; import { WhmcsOrderService } from "@bff/integrations/whmcs/services/whmcs-order.service.js"; import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js"; import { CacheService } from "@bff/infra/cache/cache.service.js"; import { extractErrorMessage } from "@bff/core/utils/error.util.js"; import type { SimOrderActivationRequest } from "@customer-portal/domain/sim"; import { randomUUID } from "crypto"; import { SimBillingService } from "./sim-management/services/sim-billing.service.js"; import { SimScheduleService } from "./sim-management/services/sim-schedule.service.js"; @Injectable() export class SimOrderActivationService { constructor( private readonly freebit: FreebitFacade, private readonly whmcsOrderService: WhmcsOrderService, private readonly mappings: MappingsService, private readonly cache: CacheService, private readonly simBilling: SimBillingService, private readonly simSchedule: SimScheduleService, @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); 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"); } 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." ); } let whmcsClientId: number; try { whmcsClientId = await this.mappings.getWhmcsClientIdOrThrow(userId); } catch (error) { await releaseProcessingFlag(); throw error; } let billingResult: | { invoice: { id: number; number: string }; transactionId?: string; } | undefined; try { billingResult = await this.simBilling.createOneTimeCharge({ clientId: whmcsClientId, userId, 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", ...(req.activationType === "Scheduled" && req.scheduledAt !== undefined ? { shipDate: req.scheduledAt } : {}), ...(req.mnp ? { mnp: { reserveNumber: req.mnp.reserveNumber || "", reserveExpireDate: req.mnp.reserveExpireDate || "", }, } : {}), }); if (req.addons && (req.addons.voiceMail || req.addons.callWaiting)) { await this.freebit.updateSimFeatures(req.msisdn, { voiceMailEnabled: !!req.addons.voiceMail, callWaitingEnabled: !!req.addons.callWaiting, }); } if (req.monthlyAmountJpy > 0) { const nextBillingIso = this.simSchedule.firstDayOfNextMonthIsoDate(); await this.whmcsOrderService.addOrder({ clientId: whmcsClientId, items: [ { productId: req.planSku, billingCycle: "monthly", quantity: 1, configOptions: { phone_number: req.msisdn, activation_date: nextBillingIso, }, customFields: { sim_type: req.simType, eid: req.eid || "", }, }, ], 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: nextBillingIso, }); } this.logger.log("SIM activation completed", { account: req.msisdn, invoiceId: billingResult.invoice.id, }); const result: { success: boolean; invoiceId: number; transactionId?: string } = { success: true, invoiceId: billingResult.invoice.id, ...(billingResult.transactionId === undefined ? {} : { transactionId: billingResult.transactionId }), }; await this.cache.set(cacheKey, result, 86400); await releaseProcessingFlag(); return result; } catch (err) { await releaseProcessingFlag(); if (billingResult?.invoice) { await this.simBilling.appendInvoiceNote( billingResult.invoice.id, `Freebit activation failed after payment: ${extractErrorMessage(err)}` ); } throw err; } } }