Assist_Design/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts
barsa a23a5593f7 refactor(bff): restructure service architecture with clearer naming conventions
- Rename integration orchestrators to facades:
  - WhmcsConnectionOrchestratorService → WhmcsConnectionFacade
  - FreebitOperationsService → FreebitFacade
  - SalesforceService → SalesforceFacade

- Rename module orchestrator:
  - SimOrchestratorService → SimOrchestrator

- Rename aggregators for clarity:
  - MeStatusService → MeStatusAggregator
  - UserProfileService → UserProfileAggregator

- Move integration facades to dedicated facades/ folders:
  - whmcs/facades/whmcs.facade.ts
  - salesforce/facades/salesforce.facade.ts
  - freebit/facades/freebit.facade.ts

This establishes clearer architectural boundaries between:
- Facades: unified entry points for integration subsystems
- Orchestrators: coordinate workflows across multiple services
- Aggregators: read-only data composition from multiple sources

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 18:50:52 +09:00

210 lines
7.1 KiB
TypeScript

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;
}
}
}