- 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>
210 lines
7.1 KiB
TypeScript
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;
|
|
}
|
|
}
|
|
}
|