Assist_Design/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts
barsa 2611e63cfd Enhance caching and response handling in catalog and subscriptions controllers
- Added Cache-Control headers to various endpoints in CatalogController and SubscriptionsController to improve caching behavior and reduce server load.
- Updated response structures to ensure consistent caching strategies across different API endpoints.
- Improved overall performance by implementing throttling and caching mechanisms for better request management.
2025-10-29 13:29:28 +09:00

191 lines
6.9 KiB
TypeScript

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