feat: add SIM call history management service with import and retrieval functionalities
feat: implement unified SIM notification service for internal and customer notifications feat: create SIM schedule service for date validation and resolution feat: develop subscriptions orchestrator service for managing subscription operations and statistics
This commit is contained in:
parent
6a7ea6e057
commit
0a0e2c6508
@ -0,0 +1,144 @@
|
|||||||
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import {
|
||||||
|
validationSuccess,
|
||||||
|
validationFailure,
|
||||||
|
type ValidationResult,
|
||||||
|
type ValidationError,
|
||||||
|
} from "./base-validator.interface.js";
|
||||||
|
import { UserMappingValidator, type UserMappingData } from "./user-mapping.validator.js";
|
||||||
|
import { PaymentValidator } from "./payment.validator.js";
|
||||||
|
import { SkuValidator } from "./sku.validator.js";
|
||||||
|
import { SimOrderValidator } from "./sim-order.validator.js";
|
||||||
|
import { InternetOrderValidator } from "./internet-order.validator.js";
|
||||||
|
import type { CheckoutItem } from "@customer-portal/domain/orders";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order validation options
|
||||||
|
*/
|
||||||
|
export interface OrderValidationOptions {
|
||||||
|
/** Whether order contains SIM products */
|
||||||
|
hasSim: boolean;
|
||||||
|
/** Whether order contains Internet products */
|
||||||
|
hasInternet: boolean;
|
||||||
|
/** Pricebook ID for SKU validation */
|
||||||
|
pricebookId: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validated order context returned on successful validation
|
||||||
|
*/
|
||||||
|
export interface ValidatedOrderContext {
|
||||||
|
/** User mapping data (userId, sfAccountId, whmcsClientId) */
|
||||||
|
mapping: UserMappingData;
|
||||||
|
/** Validated SKUs */
|
||||||
|
skus: string[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Order Composite Validator
|
||||||
|
*
|
||||||
|
* Coordinates all order validation steps in sequence.
|
||||||
|
* Collects all errors rather than failing fast for better UX.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class OrderCompositeValidator {
|
||||||
|
constructor(
|
||||||
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
|
private readonly userMappingValidator: UserMappingValidator,
|
||||||
|
private readonly paymentValidator: PaymentValidator,
|
||||||
|
private readonly skuValidator: SkuValidator,
|
||||||
|
private readonly simOrderValidator: SimOrderValidator,
|
||||||
|
private readonly internetOrderValidator: InternetOrderValidator
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate an order creation request
|
||||||
|
*
|
||||||
|
* Steps:
|
||||||
|
* 1. User mapping validation (required IDs exist)
|
||||||
|
* 2. Payment method validation (has payment on file)
|
||||||
|
* 3. SKU validation (all SKUs are valid)
|
||||||
|
* 4. Product-specific validation (SIM requirements, internet eligibility)
|
||||||
|
*
|
||||||
|
* @param userId - User ID to validate
|
||||||
|
* @param cartItems - Cart items to validate
|
||||||
|
* @param options - Validation options (hasSim, hasInternet, pricebookId)
|
||||||
|
*/
|
||||||
|
async validate(
|
||||||
|
userId: string,
|
||||||
|
cartItems: CheckoutItem[],
|
||||||
|
options: OrderValidationOptions
|
||||||
|
): Promise<ValidationResult<ValidatedOrderContext>> {
|
||||||
|
const errors: ValidationError[] = [];
|
||||||
|
const skus = cartItems.map(item => item.sku);
|
||||||
|
|
||||||
|
// Step 1: User mapping validation
|
||||||
|
const mappingResult = await this.userMappingValidator.validate(userId);
|
||||||
|
if (!mappingResult.success) {
|
||||||
|
errors.push(...(mappingResult.errors ?? []));
|
||||||
|
// Can't continue without mapping - return early
|
||||||
|
return validationFailure(errors);
|
||||||
|
}
|
||||||
|
const mapping = mappingResult.data!;
|
||||||
|
|
||||||
|
// Step 2: Payment validation
|
||||||
|
const paymentResult = await this.paymentValidator.validate(userId, mapping.whmcsClientId);
|
||||||
|
if (!paymentResult.success) {
|
||||||
|
errors.push(...(paymentResult.errors ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 3: SKU validation
|
||||||
|
const skuResult = await this.skuValidator.validate(skus, options.pricebookId);
|
||||||
|
if (!skuResult.success) {
|
||||||
|
errors.push(...(skuResult.errors ?? []));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Step 4: Product-specific validation
|
||||||
|
if (options.hasSim) {
|
||||||
|
const simResult = await this.simOrderValidator.validate(userId, skus);
|
||||||
|
if (!simResult.success) {
|
||||||
|
errors.push(...(simResult.errors ?? []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (options.hasInternet) {
|
||||||
|
const internetResult = await this.internetOrderValidator.validate(
|
||||||
|
userId,
|
||||||
|
mapping.whmcsClientId
|
||||||
|
);
|
||||||
|
if (!internetResult.success) {
|
||||||
|
errors.push(...(internetResult.errors ?? []));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Return collected errors or success
|
||||||
|
if (errors.length > 0) {
|
||||||
|
this.logger.warn({ userId, errorCount: errors.length }, "Order validation failed");
|
||||||
|
return validationFailure(errors);
|
||||||
|
}
|
||||||
|
|
||||||
|
return validationSuccess({
|
||||||
|
mapping,
|
||||||
|
skus,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and throw BadRequestException with all errors
|
||||||
|
*/
|
||||||
|
async validateOrThrow(
|
||||||
|
userId: string,
|
||||||
|
cartItems: CheckoutItem[],
|
||||||
|
options: OrderValidationOptions
|
||||||
|
): Promise<ValidatedOrderContext> {
|
||||||
|
const result = await this.validate(userId, cartItems, options);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const messages = result.errors?.map(e => e.message).join("; ");
|
||||||
|
throw new BadRequestException(messages ?? "Order validation failed");
|
||||||
|
}
|
||||||
|
|
||||||
|
return result.data!;
|
||||||
|
}
|
||||||
|
}
|
||||||
74
apps/bff/src/modules/orders/validators/payment.validator.ts
Normal file
74
apps/bff/src/modules/orders/validators/payment.validator.ts
Normal file
@ -0,0 +1,74 @@
|
|||||||
|
import { Injectable, BadRequestException, Inject } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js";
|
||||||
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
|
import {
|
||||||
|
ValidationErrorCode,
|
||||||
|
createValidationError,
|
||||||
|
validationFailure,
|
||||||
|
validationSuccess,
|
||||||
|
type ValidationResult,
|
||||||
|
} from "./base-validator.interface.js";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Payment Validator
|
||||||
|
*
|
||||||
|
* Validates that a user has a payment method on file in WHMCS before ordering.
|
||||||
|
* Follows the same validation pattern as other validators (returns ValidationResult).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class PaymentValidator {
|
||||||
|
constructor(
|
||||||
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
|
private readonly whmcs: WhmcsConnectionFacade
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate that a payment method exists for the WHMCS client
|
||||||
|
* Returns validation result indicating if payment method is on file
|
||||||
|
*/
|
||||||
|
async validate(userId: string, whmcsClientId: number): Promise<ValidationResult<void>> {
|
||||||
|
try {
|
||||||
|
const pay = await this.whmcs.getPaymentMethods({ clientid: whmcsClientId });
|
||||||
|
const paymentMethods = Array.isArray(pay.paymethods) ? pay.paymethods : [];
|
||||||
|
|
||||||
|
if (paymentMethods.length === 0) {
|
||||||
|
this.logger.warn({ userId, whmcsClientId }, "No payment method on file");
|
||||||
|
return validationFailure([
|
||||||
|
createValidationError(
|
||||||
|
ValidationErrorCode.NO_PAYMENT_METHOD,
|
||||||
|
"A payment method is required before ordering"
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
{ userId, whmcsClientId, count: paymentMethods.length },
|
||||||
|
"Payment method verified"
|
||||||
|
);
|
||||||
|
return validationSuccess();
|
||||||
|
} catch (e: unknown) {
|
||||||
|
const err = extractErrorMessage(e);
|
||||||
|
this.logger.error({ err, userId, whmcsClientId }, "Payment method verification failed");
|
||||||
|
return validationFailure([
|
||||||
|
createValidationError(
|
||||||
|
ValidationErrorCode.VALIDATION_ERROR,
|
||||||
|
"Unable to verify payment method. Please try again later."
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate and throw BadRequestException on failure
|
||||||
|
* Use this when you need to fail fast with HTTP error
|
||||||
|
*/
|
||||||
|
async validateOrThrow(userId: string, whmcsClientId: number): Promise<void> {
|
||||||
|
const result = await this.validate(userId, whmcsClientId);
|
||||||
|
|
||||||
|
if (!result.success) {
|
||||||
|
const error = result.errors?.[0];
|
||||||
|
throw new BadRequestException(error?.message ?? "Payment validation failed");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,202 @@
|
|||||||
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
||||||
|
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
||||||
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
|
import { SimValidationService } from "../sim-validation.service.js";
|
||||||
|
import { SimNotificationService } from "../support/sim-notification.service.js";
|
||||||
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
|
import type { SimReissueRequest, SimReissueFullRequest } from "@customer-portal/domain/sim";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class EsimManagementService {
|
||||||
|
constructor(
|
||||||
|
private readonly freebitService: FreebitFacade,
|
||||||
|
private readonly whmcsClientService: WhmcsClientService,
|
||||||
|
private readonly mappingsService: MappingsService,
|
||||||
|
private readonly simValidation: SimValidationService,
|
||||||
|
private readonly simNotification: SimNotificationService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get freebitBaseUrl(): string {
|
||||||
|
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reissue eSIM profile (legacy method)
|
||||||
|
*/
|
||||||
|
async reissueEsimProfile(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimReissueRequest
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
|
||||||
|
// First check if this is actually an eSIM
|
||||||
|
const simDetails = await this.freebitService.getSimDetails(account);
|
||||||
|
if (simDetails.simType !== "esim") {
|
||||||
|
throw new BadRequestException("This operation is only available for eSIM subscriptions");
|
||||||
|
}
|
||||||
|
|
||||||
|
const newEid = request.newEid;
|
||||||
|
|
||||||
|
if (newEid) {
|
||||||
|
await this.freebitService.reissueEsimProfileEnhanced(account, newEid, {
|
||||||
|
oldEid: simDetails.eid,
|
||||||
|
planCode: simDetails.planCode,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
await this.freebitService.reissueEsimProfile(account);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Successfully reissued eSIM profile for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
oldEid: simDetails.eid,
|
||||||
|
newEid: newEid || undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.simNotification.notifySimAction("Reissue eSIM", "SUCCESS", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
oldEid: simDetails.eid,
|
||||||
|
newEid: newEid || undefined,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
const sanitizedError = extractErrorMessage(error);
|
||||||
|
this.logger.error(`Failed to reissue eSIM profile for subscription ${subscriptionId}`, {
|
||||||
|
error: sanitizedError,
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
newEid: request.newEid || undefined,
|
||||||
|
});
|
||||||
|
await this.simNotification.notifySimAction("Reissue eSIM", "ERROR", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
newEid: request.newEid || undefined,
|
||||||
|
error: sanitizedError,
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reissue SIM with full flow (eSIM via PA05-41, Physical SIM via email)
|
||||||
|
*/
|
||||||
|
async reissueSim(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimReissueFullRequest
|
||||||
|
): Promise<void> {
|
||||||
|
const { account } = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
const simDetails = await this.freebitService.getSimDetails(account);
|
||||||
|
|
||||||
|
// Get customer info from WHMCS
|
||||||
|
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
|
||||||
|
const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId);
|
||||||
|
const customerName =
|
||||||
|
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
||||||
|
const customerEmail = clientDetails.email || "";
|
||||||
|
|
||||||
|
if (request.simType === "esim") {
|
||||||
|
// eSIM reissue via PA05-41
|
||||||
|
if (!request.newEid) {
|
||||||
|
throw new BadRequestException("New EID is required for eSIM reissue");
|
||||||
|
}
|
||||||
|
|
||||||
|
const oldEid = simDetails.eid;
|
||||||
|
|
||||||
|
this.logger.log(`Reissuing eSIM via PA05-41`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
oldEid,
|
||||||
|
newEid: request.newEid,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call PA05-41 with addKind: "R" for reissue
|
||||||
|
await this.freebitService.activateEsimAccountNew({
|
||||||
|
account,
|
||||||
|
eid: request.newEid,
|
||||||
|
addKind: "R",
|
||||||
|
planCode: simDetails.planCode,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send API results email to admin
|
||||||
|
await this.simNotification.sendApiResultsEmail("SIM Re-issue Request", [
|
||||||
|
{
|
||||||
|
url: `${this.freebitBaseUrl}/mvno/esim/addAcnt/`,
|
||||||
|
json: {
|
||||||
|
reissue: { oldEid },
|
||||||
|
account,
|
||||||
|
addKind: "R",
|
||||||
|
eid: request.newEid,
|
||||||
|
authKey: "[REDACTED]",
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
resultCode: "100",
|
||||||
|
status: { message: "OK", statusCode: "200" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Send customer email
|
||||||
|
const customerEmailBody = this.simNotification.buildEsimReissueEmail(
|
||||||
|
customerName,
|
||||||
|
account,
|
||||||
|
request.newEid
|
||||||
|
);
|
||||||
|
await this.simNotification.sendCustomerEmail(
|
||||||
|
customerEmail,
|
||||||
|
"SIM Re-issue Request",
|
||||||
|
customerEmailBody
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Successfully reissued eSIM for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
oldEid,
|
||||||
|
newEid: request.newEid,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Physical SIM reissue - email only, no API call
|
||||||
|
this.logger.log(`Processing physical SIM reissue request`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send admin notification email
|
||||||
|
await this.simNotification.sendApiResultsEmail(
|
||||||
|
"Physical SIM Re-issue Request",
|
||||||
|
[],
|
||||||
|
`Physical SIM reissue requested for:\nCustomer: ${customerName}\nSIM #: ${account}\nSerial #: ${simDetails.iccid || "N/A"}\nEmail: ${customerEmail}`
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send customer email
|
||||||
|
const customerEmailBody = this.simNotification.buildPhysicalSimReissueEmail(
|
||||||
|
customerName,
|
||||||
|
account
|
||||||
|
);
|
||||||
|
await this.simNotification.sendCustomerEmail(
|
||||||
|
customerEmail,
|
||||||
|
"Physical SIM Re-issue Request",
|
||||||
|
customerEmailBody
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Sent physical SIM reissue request emails`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
customerEmail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,347 @@
|
|||||||
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
||||||
|
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
||||||
|
import { SalesforceOpportunityService } from "@bff/integrations/salesforce/services/salesforce-opportunity.service.js";
|
||||||
|
import { WorkflowCaseManager } from "@bff/modules/shared/workflow/index.js";
|
||||||
|
import { SimValidationService } from "../sim-validation.service.js";
|
||||||
|
import { SubscriptionValidationCoordinator } from "../../../shared/index.js";
|
||||||
|
import type {
|
||||||
|
SimCancelRequest,
|
||||||
|
SimCancelFullRequest,
|
||||||
|
SimCancellationMonth,
|
||||||
|
SimCancellationPreview,
|
||||||
|
} from "@customer-portal/domain/sim";
|
||||||
|
import {
|
||||||
|
generateCancellationMonths,
|
||||||
|
getCancellationEffectiveDate,
|
||||||
|
getRunDateFromMonth,
|
||||||
|
} from "@customer-portal/domain/subscriptions";
|
||||||
|
import { SIM_CANCELLATION_NOTICE } from "@customer-portal/domain/opportunity";
|
||||||
|
import { SimScheduleService } from "../support/sim-schedule.service.js";
|
||||||
|
import { SimNotificationService } from "../support/sim-notification.service.js";
|
||||||
|
import { NotificationService } from "@bff/modules/notifications/notifications.service.js";
|
||||||
|
import { NOTIFICATION_SOURCE, NOTIFICATION_TYPE } from "@customer-portal/domain/notifications";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimCancellationService {
|
||||||
|
constructor(
|
||||||
|
private readonly freebitService: FreebitFacade,
|
||||||
|
private readonly whmcsClientService: WhmcsClientService,
|
||||||
|
private readonly validationCoordinator: SubscriptionValidationCoordinator,
|
||||||
|
private readonly opportunityService: SalesforceOpportunityService,
|
||||||
|
private readonly workflowCases: WorkflowCaseManager,
|
||||||
|
private readonly simValidation: SimValidationService,
|
||||||
|
private readonly simSchedule: SimScheduleService,
|
||||||
|
private readonly simNotification: SimNotificationService,
|
||||||
|
private readonly notifications: NotificationService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get freebitBaseUrl(): string {
|
||||||
|
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Calculate minimum contract end date (3 months after start, signup month not included)
|
||||||
|
*/
|
||||||
|
private calculateMinimumContractEndDate(startDateStr: string): Date | null {
|
||||||
|
if (!startDateStr || startDateStr.length < 8) return null;
|
||||||
|
|
||||||
|
// Parse YYYYMMDD format
|
||||||
|
const year = Number.parseInt(startDateStr.slice(0, 4), 10);
|
||||||
|
const month = Number.parseInt(startDateStr.slice(4, 6), 10) - 1;
|
||||||
|
const day = Number.parseInt(startDateStr.slice(6, 8), 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) return null;
|
||||||
|
|
||||||
|
const startDate = new Date(year, month, day);
|
||||||
|
// Minimum term is 3 months after signup month (signup month not included)
|
||||||
|
// e.g., signup in January = minimum term ends April 30
|
||||||
|
const endDate = new Date(startDate.getFullYear(), startDate.getMonth() + 4, 0);
|
||||||
|
|
||||||
|
return endDate;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get cancellation preview with available months
|
||||||
|
*/
|
||||||
|
async getCancellationPreview(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number
|
||||||
|
): Promise<SimCancellationPreview> {
|
||||||
|
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
const simDetails = await this.freebitService.getSimDetails(validation.account);
|
||||||
|
|
||||||
|
// Get customer info from WHMCS via coordinator
|
||||||
|
const whmcsClientId = await this.validationCoordinator.getWhmcsClientId(userId);
|
||||||
|
const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId);
|
||||||
|
const customerName =
|
||||||
|
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
||||||
|
const customerEmail = clientDetails.email || "";
|
||||||
|
|
||||||
|
// Calculate minimum contract end date
|
||||||
|
const startDate = simDetails.startDate;
|
||||||
|
const minEndDate = startDate ? this.calculateMinimumContractEndDate(startDate) : null;
|
||||||
|
const today = new Date();
|
||||||
|
const isWithinMinimumTerm = minEndDate ? today < minEndDate : false;
|
||||||
|
|
||||||
|
// Format minimum contract end date for display
|
||||||
|
let minimumContractEndDate: string | undefined;
|
||||||
|
if (minEndDate) {
|
||||||
|
const year = minEndDate.getFullYear();
|
||||||
|
const month = String(minEndDate.getMonth() + 1).padStart(2, "0");
|
||||||
|
minimumContractEndDate = `${year}-${month}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
simNumber: validation.account,
|
||||||
|
serialNumber: simDetails.iccid,
|
||||||
|
planCode: simDetails.planCode,
|
||||||
|
startDate,
|
||||||
|
minimumContractEndDate,
|
||||||
|
isWithinMinimumTerm,
|
||||||
|
availableMonths: generateCancellationMonths({
|
||||||
|
includeRunDate: true,
|
||||||
|
}) as SimCancellationMonth[],
|
||||||
|
customerEmail,
|
||||||
|
customerName,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel SIM service (legacy)
|
||||||
|
*/
|
||||||
|
async cancelSim(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimCancelRequest = {}
|
||||||
|
): Promise<void> {
|
||||||
|
let account = "";
|
||||||
|
|
||||||
|
await this.simNotification.runWithNotification(
|
||||||
|
"Cancel SIM",
|
||||||
|
{
|
||||||
|
baseContext: {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
scheduledAt: request.scheduledAt,
|
||||||
|
},
|
||||||
|
enrichSuccess: result => ({
|
||||||
|
account: result.account,
|
||||||
|
runDate: result.runDate,
|
||||||
|
}),
|
||||||
|
enrichError: () => ({
|
||||||
|
account,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
account = validation.account;
|
||||||
|
|
||||||
|
const scheduleResolution = this.simSchedule.resolveScheduledDate(request.scheduledAt);
|
||||||
|
|
||||||
|
await this.freebitService.cancelSim(account, scheduleResolution.date);
|
||||||
|
|
||||||
|
this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
runDate: scheduleResolution.date,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
account,
|
||||||
|
runDate: scheduleResolution.date,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cancel SIM service with full flow (PA02-04, Salesforce Case + Opportunity, and email notifications)
|
||||||
|
*
|
||||||
|
* Flow:
|
||||||
|
* 1. Validate SIM subscription
|
||||||
|
* 2. Call Freebit PA02-04 API to schedule cancellation
|
||||||
|
* 3. Create Salesforce Case with all form details
|
||||||
|
* 4. Update Salesforce Opportunity (if linked)
|
||||||
|
* 5. Send email notifications
|
||||||
|
*/
|
||||||
|
async cancelSimFull(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimCancelFullRequest
|
||||||
|
): Promise<void> {
|
||||||
|
// Validate account mapping via coordinator
|
||||||
|
const { whmcsClientId, sfAccountId } =
|
||||||
|
await this.validationCoordinator.validateAccountMapping(userId);
|
||||||
|
|
||||||
|
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
const account = validation.account;
|
||||||
|
const simDetails = await this.freebitService.getSimDetails(account);
|
||||||
|
|
||||||
|
// Get customer info from WHMCS
|
||||||
|
const clientDetails = await this.whmcsClientService.getClientDetails(whmcsClientId);
|
||||||
|
const customerName =
|
||||||
|
`${clientDetails.firstname || ""} ${clientDetails.lastname || ""}`.trim() || "Customer";
|
||||||
|
const customerEmail = clientDetails.email || "";
|
||||||
|
|
||||||
|
// Validate confirmations
|
||||||
|
if (!request.confirmRead || !request.confirmCancel) {
|
||||||
|
throw new BadRequestException("You must confirm both checkboxes to proceed");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate runDate and cancellation date using shared utilities
|
||||||
|
let runDate: string;
|
||||||
|
let cancellationDate: string;
|
||||||
|
try {
|
||||||
|
runDate = getRunDateFromMonth(request.cancellationMonth);
|
||||||
|
cancellationDate = getCancellationEffectiveDate(request.cancellationMonth);
|
||||||
|
} catch {
|
||||||
|
throw new BadRequestException("Invalid cancellation month format");
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Processing SIM cancellation via PA02-04`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
cancellationMonth: request.cancellationMonth,
|
||||||
|
runDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call PA02-04 cancellation API
|
||||||
|
await this.freebitService.cancelAccount(account, runDate);
|
||||||
|
|
||||||
|
this.logger.log(`Successfully cancelled SIM for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
runDate,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resolve OpportunityId via coordinator
|
||||||
|
const opportunityResolution = await this.validationCoordinator.resolveOpportunityId(
|
||||||
|
subscriptionId,
|
||||||
|
{ fallbackToSalesforce: true }
|
||||||
|
);
|
||||||
|
const opportunityId = opportunityResolution.opportunityId;
|
||||||
|
|
||||||
|
// Create Salesforce Case for cancellation via workflow manager
|
||||||
|
await this.workflowCases.notifySimCancellation({
|
||||||
|
accountId: sfAccountId,
|
||||||
|
...(opportunityId ? { opportunityId } : {}),
|
||||||
|
simAccount: account,
|
||||||
|
iccid: simDetails.iccid || "N/A",
|
||||||
|
subscriptionId,
|
||||||
|
cancellationMonth: request.cancellationMonth,
|
||||||
|
serviceEndDate: cancellationDate,
|
||||||
|
...(request.comments ? { comments: request.comments } : {}),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("SIM cancellation case created via WorkflowCaseManager", {
|
||||||
|
sfAccountIdTail: sfAccountId.slice(-4),
|
||||||
|
opportunityId: opportunityId ? opportunityId.slice(-4) : null,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Use a placeholder caseId for notification since workflow manager doesn't return it
|
||||||
|
const caseId = `cancellation:sim:${subscriptionId}:${request.cancellationMonth}`;
|
||||||
|
|
||||||
|
// Update Salesforce Opportunity (if linked via WHMCS_Service_ID__c)
|
||||||
|
if (opportunityId) {
|
||||||
|
try {
|
||||||
|
const cancellationData = {
|
||||||
|
scheduledCancellationDate: `${cancellationDate}T23:59:59.000Z`,
|
||||||
|
cancellationNotice: SIM_CANCELLATION_NOTICE.RECEIVED,
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.opportunityService.updateSimCancellationData(opportunityId, cancellationData);
|
||||||
|
|
||||||
|
this.logger.log("Opportunity updated with SIM cancellation data", {
|
||||||
|
opportunityId,
|
||||||
|
scheduledDate: cancellationDate,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
// Log but don't fail - Freebit API was already called successfully
|
||||||
|
this.logger.warn("Failed to update Opportunity with SIM cancellation data", {
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
subscriptionId,
|
||||||
|
opportunityId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
this.logger.debug("No Opportunity linked to SIM subscription", { subscriptionId });
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await this.notifications.createNotification({
|
||||||
|
userId,
|
||||||
|
type: NOTIFICATION_TYPE.CANCELLATION_SCHEDULED,
|
||||||
|
source: NOTIFICATION_SOURCE.SYSTEM,
|
||||||
|
sourceId: caseId || opportunityId || `sim:${subscriptionId}:${runDate}`,
|
||||||
|
actionUrl: `/account/services/${subscriptionId}`,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.warn("Failed to create SIM cancellation notification", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
runDate,
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Send admin notification email
|
||||||
|
const adminEmailBody = this.simNotification.buildCancellationAdminEmail({
|
||||||
|
customerName,
|
||||||
|
simNumber: account,
|
||||||
|
...(simDetails.iccid === undefined ? {} : { serialNumber: simDetails.iccid }),
|
||||||
|
cancellationMonth: request.cancellationMonth,
|
||||||
|
registeredEmail: customerEmail,
|
||||||
|
...(request.comments === undefined ? {} : { comments: request.comments }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.simNotification.sendApiResultsEmail(
|
||||||
|
"SonixNet SIM Online Cancellation",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
url: `${this.freebitBaseUrl}/master/cnclAcnt/`,
|
||||||
|
json: {
|
||||||
|
kind: "MVNO",
|
||||||
|
account,
|
||||||
|
runDate,
|
||||||
|
authKey: "[REDACTED]",
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
resultCode: "100",
|
||||||
|
status: { message: "OK", statusCode: "200" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
adminEmailBody
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send confirmation email to customer
|
||||||
|
const confirmationSubject = "SonixNet SIM Cancellation Confirmation";
|
||||||
|
const confirmationBody = `Dear ${customerName},
|
||||||
|
|
||||||
|
Your cancellation request for SIM #${account} has been confirmed.
|
||||||
|
|
||||||
|
The cancellation will take effect at the end of ${request.cancellationMonth}.
|
||||||
|
|
||||||
|
If you have any questions, please contact us at info@asolutions.co.jp
|
||||||
|
|
||||||
|
With best regards,
|
||||||
|
Assist Solutions Customer Support
|
||||||
|
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
|
||||||
|
Email: info@asolutions.co.jp`;
|
||||||
|
|
||||||
|
await this.simNotification.sendCustomerEmail(
|
||||||
|
customerEmail,
|
||||||
|
confirmationSubject,
|
||||||
|
confirmationBody
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,353 @@
|
|||||||
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
||||||
|
import { SimValidationService } from "../sim-validation.service.js";
|
||||||
|
import type {
|
||||||
|
SimPlanChangeRequest,
|
||||||
|
SimFeaturesUpdateRequest,
|
||||||
|
SimChangePlanFullRequest,
|
||||||
|
SimAvailablePlan,
|
||||||
|
} from "@customer-portal/domain/sim";
|
||||||
|
import { SimScheduleService } from "../support/sim-schedule.service.js";
|
||||||
|
import { SimManagementQueueService } from "../../queue/sim-management.queue.js";
|
||||||
|
import { SimNotificationService } from "../support/sim-notification.service.js";
|
||||||
|
import { SimServicesService } from "@bff/modules/services/application/sim-services.service.js";
|
||||||
|
|
||||||
|
// Mapping from Salesforce SKU to Freebit plan code
|
||||||
|
const SKU_TO_FREEBIT_PLAN_CODE: Record<string, string> = {
|
||||||
|
"SIM-DATA-VOICE-5GB": "PASI_5G",
|
||||||
|
"SIM-DATA-VOICE-10GB": "PASI_10G",
|
||||||
|
"SIM-DATA-VOICE-25GB": "PASI_25G",
|
||||||
|
"SIM-DATA-VOICE-50GB": "PASI_50G",
|
||||||
|
"SIM-DATA-ONLY-5GB": "PASI_5G_DATA",
|
||||||
|
"SIM-DATA-ONLY-10GB": "PASI_10G_DATA",
|
||||||
|
"SIM-DATA-ONLY-25GB": "PASI_25G_DATA",
|
||||||
|
"SIM-DATA-ONLY-50GB": "PASI_50G_DATA",
|
||||||
|
"SIM-VOICE-ONLY": "PASI_VOICE",
|
||||||
|
};
|
||||||
|
|
||||||
|
// Reverse mapping: Freebit plan code to Salesforce SKU
|
||||||
|
const FREEBIT_PLAN_CODE_TO_SKU: Record<string, string> = Object.fromEntries(
|
||||||
|
Object.entries(SKU_TO_FREEBIT_PLAN_CODE).map(([sku, code]) => [code, sku])
|
||||||
|
);
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimPlanService {
|
||||||
|
constructor(
|
||||||
|
private readonly freebitService: FreebitFacade,
|
||||||
|
private readonly simValidation: SimValidationService,
|
||||||
|
private readonly simSchedule: SimScheduleService,
|
||||||
|
private readonly simQueue: SimManagementQueueService,
|
||||||
|
private readonly simNotification: SimNotificationService,
|
||||||
|
private readonly simCatalog: SimServicesService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get freebitBaseUrl(): string {
|
||||||
|
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available plans for plan change
|
||||||
|
* Filters by current plan type (e.g., only show DataSmsVoice plans if current is DataSmsVoice)
|
||||||
|
*/
|
||||||
|
async getAvailablePlans(userId: string, subscriptionId: number): Promise<SimAvailablePlan[]> {
|
||||||
|
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
const simDetails = await this.freebitService.getSimDetails(validation.account);
|
||||||
|
const currentPlanCode = simDetails.planCode;
|
||||||
|
const currentSku = FREEBIT_PLAN_CODE_TO_SKU[currentPlanCode];
|
||||||
|
|
||||||
|
// Get all plans from Salesforce
|
||||||
|
const allPlans = await this.simCatalog.getPlans();
|
||||||
|
|
||||||
|
// Determine current plan type
|
||||||
|
let currentPlanType: string | undefined;
|
||||||
|
if (currentSku) {
|
||||||
|
const currentPlan = allPlans.find(p => p.sku === currentSku);
|
||||||
|
currentPlanType = currentPlan?.simPlanType;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter plans by type (e.g., only show DataSmsVoice if current is DataSmsVoice)
|
||||||
|
const filteredPlans = currentPlanType
|
||||||
|
? allPlans.filter(p => p.simPlanType === currentPlanType)
|
||||||
|
: allPlans.filter(p => !p.simHasFamilyDiscount); // Default: non-family plans
|
||||||
|
|
||||||
|
// Map to SimAvailablePlan with Freebit codes
|
||||||
|
return filteredPlans.map(plan => {
|
||||||
|
const freebitPlanCode = SKU_TO_FREEBIT_PLAN_CODE[plan.sku] || plan.sku;
|
||||||
|
return {
|
||||||
|
...plan,
|
||||||
|
freebitPlanCode,
|
||||||
|
isCurrentPlan: freebitPlanCode === currentPlanCode,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Freebit plan code from Salesforce SKU
|
||||||
|
*/
|
||||||
|
getFreebitPlanCode(sku: string): string | undefined {
|
||||||
|
return SKU_TO_FREEBIT_PLAN_CODE[sku];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get Salesforce SKU from Freebit plan code
|
||||||
|
*/
|
||||||
|
getSalesforceSku(planCode: string): string | undefined {
|
||||||
|
return FREEBIT_PLAN_CODE_TO_SKU[planCode];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change SIM plan (basic)
|
||||||
|
*/
|
||||||
|
async changeSimPlan(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimPlanChangeRequest
|
||||||
|
): Promise<{ ipv4?: string; ipv6?: string }> {
|
||||||
|
let account = "";
|
||||||
|
const assignGlobalIp = request.assignGlobalIp ?? false;
|
||||||
|
|
||||||
|
const response = await this.simNotification.runWithNotification(
|
||||||
|
"Change Plan",
|
||||||
|
{
|
||||||
|
baseContext: {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
newPlanCode: request.newPlanCode,
|
||||||
|
assignGlobalIp,
|
||||||
|
},
|
||||||
|
enrichSuccess: result => ({
|
||||||
|
account: result.account,
|
||||||
|
scheduledAt: result.scheduledAt,
|
||||||
|
}),
|
||||||
|
enrichError: () => ({
|
||||||
|
account,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
account = validation.account;
|
||||||
|
|
||||||
|
if (!request.newPlanCode || request.newPlanCode.length < 3) {
|
||||||
|
throw new BadRequestException("Invalid plan code");
|
||||||
|
}
|
||||||
|
|
||||||
|
const scheduleResolution = this.simSchedule.resolveScheduledDate(request.scheduledAt);
|
||||||
|
|
||||||
|
this.logger.log("Submitting SIM plan change request", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
newPlanCode: request.newPlanCode,
|
||||||
|
scheduledAt: scheduleResolution.date,
|
||||||
|
assignGlobalIp,
|
||||||
|
scheduleOrigin: scheduleResolution.source,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await this.freebitService.changeSimPlan(account, request.newPlanCode, {
|
||||||
|
assignGlobalIp,
|
||||||
|
scheduledAt: scheduleResolution.date,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
newPlanCode: request.newPlanCode,
|
||||||
|
scheduledAt: scheduleResolution.date,
|
||||||
|
assignGlobalIp,
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
...result,
|
||||||
|
account,
|
||||||
|
scheduledAt: scheduleResolution.date,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(response.ipv4 === undefined ? {} : { ipv4: response.ipv4 }),
|
||||||
|
...(response.ipv6 === undefined ? {} : { ipv6: response.ipv6 }),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Change SIM plan with enhanced notifications and Salesforce SKU mapping
|
||||||
|
*/
|
||||||
|
async changeSimPlanFull(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimChangePlanFullRequest
|
||||||
|
): Promise<{ ipv4?: string; ipv6?: string; scheduledAt?: string }> {
|
||||||
|
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
const account = validation.account;
|
||||||
|
|
||||||
|
// Get or derive Freebit plan code from SKU
|
||||||
|
const freebitPlanCode = SKU_TO_FREEBIT_PLAN_CODE[request.newPlanSku] || request.newPlanCode;
|
||||||
|
|
||||||
|
if (!freebitPlanCode || freebitPlanCode.length < 3) {
|
||||||
|
throw new BadRequestException("Invalid plan code");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always schedule for 1st of following month
|
||||||
|
const nextMonth = new Date();
|
||||||
|
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||||
|
nextMonth.setDate(1);
|
||||||
|
const year = nextMonth.getFullYear();
|
||||||
|
const month = String(nextMonth.getMonth() + 1).padStart(2, "0");
|
||||||
|
const scheduledAt = `${year}${month}01`;
|
||||||
|
|
||||||
|
this.logger.log("Submitting SIM plan change request (full)", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
newPlanSku: request.newPlanSku,
|
||||||
|
freebitPlanCode,
|
||||||
|
scheduledAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
const result = await this.freebitService.changeSimPlan(account, freebitPlanCode, {
|
||||||
|
assignGlobalIp: request.assignGlobalIp ?? false,
|
||||||
|
scheduledAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(`Successfully changed SIM plan for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
newPlanCode: freebitPlanCode,
|
||||||
|
scheduledAt,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send API results email
|
||||||
|
await this.simNotification.sendApiResultsEmail(
|
||||||
|
"API results - Plan Change",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
url: `${this.freebitBaseUrl}/mvno/changePlan/`,
|
||||||
|
json: {
|
||||||
|
account,
|
||||||
|
planCode: freebitPlanCode,
|
||||||
|
runTime: scheduledAt,
|
||||||
|
authKey: "[REDACTED]",
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
resultCode: "100",
|
||||||
|
status: { message: "OK", statusCode: "200" },
|
||||||
|
ipv4: result.ipv4 || "",
|
||||||
|
ipv6: result.ipv6 || "",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
`Plan changed to: ${request.newPlanName || freebitPlanCode}\nScheduled for: ${scheduledAt}`
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(result.ipv4 === undefined ? {} : { ipv4: result.ipv4 }),
|
||||||
|
...(result.ipv6 === undefined ? {} : { ipv6: result.ipv6 }),
|
||||||
|
scheduledAt,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update SIM features (voicemail, call waiting, roaming, network type)
|
||||||
|
*/
|
||||||
|
async updateSimFeatures(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
request: SimFeaturesUpdateRequest
|
||||||
|
): Promise<void> {
|
||||||
|
let account = "";
|
||||||
|
|
||||||
|
await this.simNotification.runWithNotification(
|
||||||
|
"Update Features",
|
||||||
|
{
|
||||||
|
baseContext: {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
...request,
|
||||||
|
},
|
||||||
|
enrichSuccess: () => ({
|
||||||
|
account,
|
||||||
|
}),
|
||||||
|
enrichError: () => ({
|
||||||
|
account,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
account = validation.account;
|
||||||
|
|
||||||
|
if (request.networkType && !["4G", "5G"].includes(request.networkType)) {
|
||||||
|
throw new BadRequestException('networkType must be either "4G" or "5G"');
|
||||||
|
}
|
||||||
|
|
||||||
|
const doVoice =
|
||||||
|
typeof request.voiceMailEnabled === "boolean" ||
|
||||||
|
typeof request.callWaitingEnabled === "boolean" ||
|
||||||
|
typeof request.internationalRoamingEnabled === "boolean";
|
||||||
|
const doContract = typeof request.networkType === "string";
|
||||||
|
|
||||||
|
if (doVoice && doContract) {
|
||||||
|
await this.freebitService.updateSimFeatures(account, {
|
||||||
|
...(request.voiceMailEnabled === undefined
|
||||||
|
? {}
|
||||||
|
: { voiceMailEnabled: request.voiceMailEnabled }),
|
||||||
|
...(request.callWaitingEnabled === undefined
|
||||||
|
? {}
|
||||||
|
: { callWaitingEnabled: request.callWaitingEnabled }),
|
||||||
|
...(request.internationalRoamingEnabled === undefined
|
||||||
|
? {}
|
||||||
|
: { internationalRoamingEnabled: request.internationalRoamingEnabled }),
|
||||||
|
});
|
||||||
|
|
||||||
|
await this.simQueue.scheduleNetworkTypeChange({
|
||||||
|
account,
|
||||||
|
networkType: request.networkType as "4G" | "5G",
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("Scheduled contract line change via queue after voice option change", {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
networkType: request.networkType,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
// Filter out undefined values to satisfy exactOptionalPropertyTypes
|
||||||
|
const filteredRequest: {
|
||||||
|
voiceMailEnabled?: boolean;
|
||||||
|
callWaitingEnabled?: boolean;
|
||||||
|
internationalRoamingEnabled?: boolean;
|
||||||
|
networkType?: "4G" | "5G";
|
||||||
|
} = {};
|
||||||
|
if (request.voiceMailEnabled !== undefined) {
|
||||||
|
filteredRequest.voiceMailEnabled = request.voiceMailEnabled;
|
||||||
|
}
|
||||||
|
if (request.callWaitingEnabled !== undefined) {
|
||||||
|
filteredRequest.callWaitingEnabled = request.callWaitingEnabled;
|
||||||
|
}
|
||||||
|
if (request.internationalRoamingEnabled !== undefined) {
|
||||||
|
filteredRequest.internationalRoamingEnabled = request.internationalRoamingEnabled;
|
||||||
|
}
|
||||||
|
if (request.networkType !== undefined) {
|
||||||
|
filteredRequest.networkType = request.networkType;
|
||||||
|
}
|
||||||
|
await this.freebitService.updateSimFeatures(account, filteredRequest);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Updated SIM features for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
...request,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,255 @@
|
|||||||
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { FreebitFacade } from "@bff/integrations/freebit/facades/freebit.facade.js";
|
||||||
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
|
import { SimValidationService } from "../sim-validation.service.js";
|
||||||
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
|
import type { SimTopUpRequest } from "@customer-portal/domain/sim";
|
||||||
|
import { SimBillingService } from "../queries/sim-billing.service.js";
|
||||||
|
import { SimNotificationService } from "../support/sim-notification.service.js";
|
||||||
|
import { SimTopUpPricingService } from "../queries/sim-topup-pricing.service.js";
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimTopUpService {
|
||||||
|
constructor(
|
||||||
|
private readonly freebitService: FreebitFacade,
|
||||||
|
private readonly mappingsService: MappingsService,
|
||||||
|
private readonly simValidation: SimValidationService,
|
||||||
|
private readonly simBilling: SimBillingService,
|
||||||
|
private readonly simNotification: SimNotificationService,
|
||||||
|
private readonly configService: ConfigService,
|
||||||
|
private readonly simTopUpPricing: SimTopUpPricingService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
private get whmcsBaseUrl(): string {
|
||||||
|
return (
|
||||||
|
this.configService.get<string>("WHMCS_BASE_URL") ||
|
||||||
|
"https://accounts.asolutions.co.jp/includes/api.php"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
private get freebitBaseUrl(): string {
|
||||||
|
return this.configService.get<string>("FREEBIT_BASE_URL") || "https://i1.mvno.net/emptool/api";
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Top up SIM data quota with payment processing
|
||||||
|
* Pricing: 1GB = 500 JPY
|
||||||
|
*/
|
||||||
|
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
||||||
|
let latestAccount = "";
|
||||||
|
|
||||||
|
await this.simNotification.runWithNotification(
|
||||||
|
"Top Up Data",
|
||||||
|
{
|
||||||
|
baseContext: {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
quotaMb: request.quotaMb,
|
||||||
|
},
|
||||||
|
enrichSuccess: meta => ({
|
||||||
|
account: meta.account,
|
||||||
|
costJpy: meta.costJpy,
|
||||||
|
invoiceId: meta.invoiceId,
|
||||||
|
transactionId: meta.transactionId,
|
||||||
|
}),
|
||||||
|
enrichError: () => ({
|
||||||
|
account: latestAccount,
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
latestAccount = validation.account;
|
||||||
|
|
||||||
|
if (request.quotaMb <= 0) {
|
||||||
|
throw new BadRequestException("Quota must be greater than 0MB");
|
||||||
|
}
|
||||||
|
|
||||||
|
const pricing = await this.simTopUpPricing.getTopUpPricing();
|
||||||
|
const minQuotaMb = pricing.minQuotaMb;
|
||||||
|
const maxQuotaMb = pricing.maxQuotaMb;
|
||||||
|
const quotaGb = request.quotaMb / 1000;
|
||||||
|
const units = Math.ceil(quotaGb);
|
||||||
|
const costJpy = units * pricing.pricePerGbJpy;
|
||||||
|
|
||||||
|
if (request.quotaMb < minQuotaMb || request.quotaMb > maxQuotaMb) {
|
||||||
|
throw new BadRequestException(
|
||||||
|
`Quota must be between ${minQuotaMb}MB and ${maxQuotaMb}MB for Freebit API compatibility`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
|
||||||
|
|
||||||
|
this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account: latestAccount,
|
||||||
|
quotaMb: request.quotaMb,
|
||||||
|
quotaGb: quotaGb.toFixed(2),
|
||||||
|
costJpy,
|
||||||
|
});
|
||||||
|
|
||||||
|
const billing = await this.simBilling.createOneTimeCharge({
|
||||||
|
clientId: whmcsClientId,
|
||||||
|
userId,
|
||||||
|
description: `SIM Data Top-up: ${units}GB for ${latestAccount}`,
|
||||||
|
amountJpy: costJpy,
|
||||||
|
currency: "JPY",
|
||||||
|
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000),
|
||||||
|
notes: `Subscription ID: ${subscriptionId}, Phone: ${latestAccount}`,
|
||||||
|
failureNotesPrefix: "Payment capture failed",
|
||||||
|
publicErrorMessage: "SIM top-up failed: payment could not be processed",
|
||||||
|
metadata: { subscriptionId },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Call Freebit API to add quota
|
||||||
|
let freebitResult: {
|
||||||
|
resultCode: string;
|
||||||
|
status: { message: string; statusCode: string };
|
||||||
|
} | null = null;
|
||||||
|
try {
|
||||||
|
await this.freebitService.topUpSim(latestAccount, request.quotaMb, {});
|
||||||
|
freebitResult = { resultCode: "100", status: { message: "OK", statusCode: "200" } };
|
||||||
|
} catch (freebitError) {
|
||||||
|
await this.handleFreebitFailureAfterPayment(
|
||||||
|
freebitError,
|
||||||
|
billing.invoice,
|
||||||
|
billing.transactionId || "unknown",
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
latestAccount,
|
||||||
|
request.quotaMb
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, {
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account: latestAccount,
|
||||||
|
quotaMb: request.quotaMb,
|
||||||
|
costJpy,
|
||||||
|
invoiceId: billing.invoice.id,
|
||||||
|
transactionId: billing.transactionId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Send API results email notification
|
||||||
|
const today = new Date();
|
||||||
|
const dateStr = today.toISOString().split("T")[0];
|
||||||
|
const dueDate = new Date(today.getTime() + 2 * 24 * 60 * 60 * 1000)
|
||||||
|
.toISOString()
|
||||||
|
.split("T")[0];
|
||||||
|
|
||||||
|
await this.simNotification.sendApiResultsEmail("API results", [
|
||||||
|
{
|
||||||
|
url: this.whmcsBaseUrl,
|
||||||
|
senddata: {
|
||||||
|
itemdescription1: `Top-up data (${units}GB)\nSIM Number: ${latestAccount}`,
|
||||||
|
itemamount1: String(costJpy),
|
||||||
|
userid: String(whmcsClientId),
|
||||||
|
date: dateStr,
|
||||||
|
responsetype: "json",
|
||||||
|
itemtaxed1: "1",
|
||||||
|
action: "CreateInvoice",
|
||||||
|
duedate: dueDate,
|
||||||
|
paymentmethod: "stripe",
|
||||||
|
sendinvoice: "1",
|
||||||
|
},
|
||||||
|
result: {
|
||||||
|
result: "success",
|
||||||
|
invoiceid: billing.invoice.id,
|
||||||
|
status: billing.invoice.status,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: this.whmcsBaseUrl,
|
||||||
|
senddata: {
|
||||||
|
responsetype: "json",
|
||||||
|
action: "CapturePayment",
|
||||||
|
invoiceid: billing.invoice.id,
|
||||||
|
},
|
||||||
|
result: { result: "success" },
|
||||||
|
},
|
||||||
|
{
|
||||||
|
url: `${this.freebitBaseUrl}/master/addSpec/`,
|
||||||
|
json: {
|
||||||
|
quota: request.quotaMb,
|
||||||
|
kind: "MVNO",
|
||||||
|
account: latestAccount,
|
||||||
|
authKey: "[REDACTED]",
|
||||||
|
},
|
||||||
|
result: freebitResult || {
|
||||||
|
resultCode: "100",
|
||||||
|
status: { message: "OK", statusCode: "200" },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
account: latestAccount,
|
||||||
|
costJpy,
|
||||||
|
invoiceId: billing.invoice.id,
|
||||||
|
transactionId: billing.transactionId,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handle Freebit API failure after successful payment
|
||||||
|
*/
|
||||||
|
private async handleFreebitFailureAfterPayment(
|
||||||
|
freebitError: unknown,
|
||||||
|
invoice: { id: number; number: string },
|
||||||
|
transactionId: string,
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
account: string,
|
||||||
|
quotaMb: number
|
||||||
|
): Promise<void> {
|
||||||
|
this.logger.error(
|
||||||
|
`Freebit API failed after successful payment for subscription ${subscriptionId}`,
|
||||||
|
{
|
||||||
|
error: extractErrorMessage(freebitError),
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
account,
|
||||||
|
quotaMb,
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
transactionId,
|
||||||
|
paymentCaptured: true,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Add a note to the invoice about the Freebit failure
|
||||||
|
try {
|
||||||
|
await this.simBilling.appendInvoiceNote(
|
||||||
|
invoice.id,
|
||||||
|
`Payment successful but SIM top-up failed: ${extractErrorMessage(
|
||||||
|
freebitError
|
||||||
|
)}. Manual intervention required.`
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Added failure note to invoice ${invoice.id}`, {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
reason: "Freebit API failure after payment",
|
||||||
|
});
|
||||||
|
} catch (updateError) {
|
||||||
|
this.logger.error(`Failed to update invoice ${invoice.id} with failure note`, {
|
||||||
|
invoiceId: invoice.id,
|
||||||
|
updateError: extractErrorMessage(updateError),
|
||||||
|
originalError: extractErrorMessage(freebitError),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Refund logic is handled by the caller (via top-up failure handling)
|
||||||
|
// Automatic refunds should be implemented at the payment processing layer
|
||||||
|
// to ensure consistency across all failure scenarios.
|
||||||
|
// For manual refunds, use the WHMCS admin panel or dedicated refund endpoints.
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,84 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for formatting call history data for display.
|
||||||
|
* Handles time, duration, and phone number formatting.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SimCallHistoryFormatterService {
|
||||||
|
/**
|
||||||
|
* Convert HHMMSS to HH:MM:SS display format
|
||||||
|
*/
|
||||||
|
formatTime(timeStr: string): string {
|
||||||
|
if (!timeStr || timeStr.length < 6) return timeStr;
|
||||||
|
const clean = timeStr.replace(/[^0-9]/g, "").padStart(6, "0");
|
||||||
|
return `${clean.slice(0, 2)}:${clean.slice(2, 4)}:${clean.slice(4, 6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert seconds to readable duration format (Xh Ym Zs)
|
||||||
|
*/
|
||||||
|
formatDuration(seconds: number): string {
|
||||||
|
const hours = Math.floor(seconds / 3600);
|
||||||
|
const minutes = Math.floor((seconds % 3600) / 60);
|
||||||
|
const secs = seconds % 60;
|
||||||
|
|
||||||
|
if (hours > 0) {
|
||||||
|
return `${hours}h ${minutes}m ${secs}s`;
|
||||||
|
} else if (minutes > 0) {
|
||||||
|
return `${minutes}m ${secs}s`;
|
||||||
|
} else {
|
||||||
|
return `${secs}s`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format Japanese phone numbers with appropriate separators
|
||||||
|
*/
|
||||||
|
formatPhoneNumber(phone: string): string {
|
||||||
|
if (!phone) return phone;
|
||||||
|
const clean = phone.replace(/[^0-9+]/g, "");
|
||||||
|
|
||||||
|
// 080-XXXX-XXXX or 070-XXXX-XXXX or 090-XXXX-XXXX format
|
||||||
|
if (
|
||||||
|
clean.length === 11 &&
|
||||||
|
(clean.startsWith("080") || clean.startsWith("070") || clean.startsWith("090"))
|
||||||
|
) {
|
||||||
|
return `${clean.slice(0, 3)}-${clean.slice(3, 7)}-${clean.slice(7)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 03-XXXX-XXXX format (landline)
|
||||||
|
if (clean.length === 10 && clean.startsWith("0")) {
|
||||||
|
return `${clean.slice(0, 2)}-${clean.slice(2, 6)}-${clean.slice(6)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
return clean;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get default month for call history queries (2 months ago)
|
||||||
|
*/
|
||||||
|
getDefaultMonth(): string {
|
||||||
|
const now = new Date();
|
||||||
|
now.setMonth(now.getMonth() - 2);
|
||||||
|
const year = now.getFullYear();
|
||||||
|
const month = String(now.getMonth() + 1).padStart(2, "0");
|
||||||
|
return `${year}-${month}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Format a date to YYYY-MM format
|
||||||
|
*/
|
||||||
|
formatYearMonth(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
return `${year}-${month}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert YYYYMM to YYYY-MM format
|
||||||
|
*/
|
||||||
|
normalizeMonth(yearMonth: string): string {
|
||||||
|
return `${yearMonth.slice(0, 4)}-${yearMonth.slice(4, 6)}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,296 @@
|
|||||||
|
import { Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
// SmsType enum to match Prisma schema
|
||||||
|
type SmsType = "DOMESTIC" | "INTERNATIONAL";
|
||||||
|
|
||||||
|
export interface DomesticCallRecord {
|
||||||
|
account: string;
|
||||||
|
callDate: Date;
|
||||||
|
callTime: string;
|
||||||
|
calledTo: string;
|
||||||
|
location: string | null;
|
||||||
|
durationSec: number;
|
||||||
|
chargeYen: number;
|
||||||
|
month: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InternationalCallRecord {
|
||||||
|
account: string;
|
||||||
|
callDate: Date;
|
||||||
|
startTime: string;
|
||||||
|
stopTime: string | null;
|
||||||
|
country: string | null;
|
||||||
|
calledTo: string;
|
||||||
|
durationSec: number;
|
||||||
|
chargeYen: number;
|
||||||
|
month: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SmsRecord {
|
||||||
|
account: string;
|
||||||
|
smsDate: Date;
|
||||||
|
smsTime: string;
|
||||||
|
sentTo: string;
|
||||||
|
smsType: SmsType;
|
||||||
|
month: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedTalkDetail {
|
||||||
|
domestic: DomesticCallRecord[];
|
||||||
|
international: InternationalCallRecord[];
|
||||||
|
skipped: number;
|
||||||
|
errors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ParsedSmsDetail {
|
||||||
|
records: SmsRecord[];
|
||||||
|
skipped: number;
|
||||||
|
errors: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for parsing call history CSV files from Freebit SFTP.
|
||||||
|
* Handles the specific CSV formats for talk detail and SMS detail files.
|
||||||
|
*
|
||||||
|
* Talk Detail CSV Columns:
|
||||||
|
* 1. Customer phone number
|
||||||
|
* 2. Date (YYYYMMDD)
|
||||||
|
* 3. Start time (HHMMSS)
|
||||||
|
* 4. Called to phone number
|
||||||
|
* 5. dome/tointl (call type)
|
||||||
|
* 6. Location
|
||||||
|
* 7. Duration (MMSST format - minutes, seconds, tenths)
|
||||||
|
* 8. Tokens (each token = 10 yen)
|
||||||
|
* 9. Alternative charge (if location is "他社")
|
||||||
|
*
|
||||||
|
* SMS Detail CSV Columns:
|
||||||
|
* 1. Customer phone number
|
||||||
|
* 2. Date (YYYYMMDD)
|
||||||
|
* 3. Start time (HHMMSS)
|
||||||
|
* 4. SMS sent to phone number
|
||||||
|
* 5. dome/tointl
|
||||||
|
* 6. SMS type (SMS or 国際SMS)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SimCallHistoryParserService {
|
||||||
|
/**
|
||||||
|
* Parse talk detail CSV content into domestic and international call records
|
||||||
|
*/
|
||||||
|
parseTalkDetailCsv(content: string, month: string): ParsedTalkDetail {
|
||||||
|
const domestic: DomesticCallRecord[] = [];
|
||||||
|
const international: InternationalCallRecord[] = [];
|
||||||
|
let skipped = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
const lines = content.split(/\r?\n/).filter(line => line.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const columns = this.parseCsvLine(line);
|
||||||
|
|
||||||
|
if (columns.length < 8) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [
|
||||||
|
phoneNumber = "",
|
||||||
|
dateStr = "",
|
||||||
|
timeStr = "",
|
||||||
|
calledTo = "",
|
||||||
|
callType = "",
|
||||||
|
location = "",
|
||||||
|
durationStr = "",
|
||||||
|
tokensStr = "",
|
||||||
|
altChargeStr = "",
|
||||||
|
] = columns;
|
||||||
|
|
||||||
|
// Parse date
|
||||||
|
const callDate = this.parseDate(dateStr);
|
||||||
|
if (!callDate) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse duration - format is MMSST (minutes, seconds, tenths)
|
||||||
|
// e.g., 36270 = 36 min 27.0 sec, 320 = 0 min 32.0 sec
|
||||||
|
const durationSec = this.parseDuration(durationStr);
|
||||||
|
|
||||||
|
// Parse charge: use tokens * 10 yen, or alt charge if location is "他社"
|
||||||
|
const chargeYen = this.parseCharge(location, tokensStr, altChargeStr);
|
||||||
|
|
||||||
|
// Clean account number (remove dashes, spaces)
|
||||||
|
const account = this.cleanPhoneNumber(phoneNumber);
|
||||||
|
|
||||||
|
// Clean called-to number
|
||||||
|
const cleanCalledTo = this.cleanPhoneNumber(calledTo);
|
||||||
|
|
||||||
|
if (callType === "dome" || callType === "domestic") {
|
||||||
|
domestic.push({
|
||||||
|
account,
|
||||||
|
callDate,
|
||||||
|
callTime: timeStr,
|
||||||
|
calledTo: cleanCalledTo,
|
||||||
|
location: location || null,
|
||||||
|
durationSec,
|
||||||
|
chargeYen,
|
||||||
|
month,
|
||||||
|
});
|
||||||
|
} else if (callType === "tointl" || callType === "international") {
|
||||||
|
international.push({
|
||||||
|
account,
|
||||||
|
callDate,
|
||||||
|
startTime: timeStr,
|
||||||
|
stopTime: null,
|
||||||
|
country: location || null,
|
||||||
|
calledTo: cleanCalledTo,
|
||||||
|
durationSec,
|
||||||
|
chargeYen,
|
||||||
|
month,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
skipped++;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { domestic, international, skipped, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse SMS detail CSV content
|
||||||
|
*/
|
||||||
|
parseSmsDetailCsv(content: string, month: string): ParsedSmsDetail {
|
||||||
|
const records: SmsRecord[] = [];
|
||||||
|
let skipped = 0;
|
||||||
|
let errors = 0;
|
||||||
|
|
||||||
|
const lines = content.split(/\r?\n/).filter(line => line.trim());
|
||||||
|
|
||||||
|
for (const line of lines) {
|
||||||
|
try {
|
||||||
|
const columns = this.parseCsvLine(line);
|
||||||
|
|
||||||
|
if (columns.length < 6) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const [phoneNumber = "", dateStr = "", timeStr = "", sentTo = "", , smsTypeStr = ""] =
|
||||||
|
columns;
|
||||||
|
|
||||||
|
// Parse date
|
||||||
|
const smsDate = this.parseDate(dateStr);
|
||||||
|
if (!smsDate) {
|
||||||
|
skipped++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clean account number
|
||||||
|
const account = this.cleanPhoneNumber(phoneNumber);
|
||||||
|
|
||||||
|
// Clean sent-to number
|
||||||
|
const cleanSentTo = this.cleanPhoneNumber(sentTo);
|
||||||
|
|
||||||
|
// Determine SMS type
|
||||||
|
const smsType: SmsType = smsTypeStr.includes("国際") ? "INTERNATIONAL" : "DOMESTIC";
|
||||||
|
|
||||||
|
records.push({
|
||||||
|
account,
|
||||||
|
smsDate,
|
||||||
|
smsTime: timeStr,
|
||||||
|
sentTo: cleanSentTo,
|
||||||
|
smsType,
|
||||||
|
month,
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
errors++;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { records, skipped, errors };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse a CSV line handling quoted fields and escaped quotes
|
||||||
|
*/
|
||||||
|
private parseCsvLine(line: string): string[] {
|
||||||
|
const normalizedLine = line.replace(/\r$/, "").replace(/^\uFEFF/, "");
|
||||||
|
const result: string[] = [];
|
||||||
|
let current = "";
|
||||||
|
let inQuotes = false;
|
||||||
|
|
||||||
|
for (let i = 0; i < normalizedLine.length; i++) {
|
||||||
|
const char = normalizedLine[i];
|
||||||
|
if (char === '"') {
|
||||||
|
if (inQuotes && normalizedLine[i + 1] === '"') {
|
||||||
|
current += '"';
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
inQuotes = !inQuotes;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (char === "," && !inQuotes) {
|
||||||
|
result.push(current.trim());
|
||||||
|
current = "";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
current += char;
|
||||||
|
}
|
||||||
|
result.push(current.trim());
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse YYYYMMDD date string to Date object
|
||||||
|
*/
|
||||||
|
private parseDate(dateStr: string): Date | null {
|
||||||
|
if (!dateStr || dateStr.length < 8) return null;
|
||||||
|
|
||||||
|
const clean = dateStr.replace(/[^0-9]/g, "");
|
||||||
|
if (clean.length < 8) return null;
|
||||||
|
|
||||||
|
const year = Number.parseInt(clean.slice(0, 4), 10);
|
||||||
|
const month = Number.parseInt(clean.slice(4, 6), 10) - 1;
|
||||||
|
const day = Number.parseInt(clean.slice(6, 8), 10);
|
||||||
|
|
||||||
|
if (Number.isNaN(year) || Number.isNaN(month) || Number.isNaN(day)) return null;
|
||||||
|
|
||||||
|
return new Date(year, month, day);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse duration string (MMSST format) to seconds
|
||||||
|
*/
|
||||||
|
private parseDuration(durationStr: string): number {
|
||||||
|
const durationVal = durationStr.padStart(5, "0");
|
||||||
|
const minutes = Number.parseInt(durationVal.slice(0, -3), 10) || 0;
|
||||||
|
const seconds = Number.parseInt(durationVal.slice(-3, -1), 10) || 0;
|
||||||
|
return minutes * 60 + seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse charge from tokens or alternative charge
|
||||||
|
*/
|
||||||
|
private parseCharge(
|
||||||
|
location: string | undefined,
|
||||||
|
tokensStr: string,
|
||||||
|
altChargeStr: string | undefined
|
||||||
|
): number {
|
||||||
|
if (location && location.includes("他社") && altChargeStr) {
|
||||||
|
return Number.parseInt(altChargeStr, 10) || 0;
|
||||||
|
}
|
||||||
|
return (Number.parseInt(tokensStr, 10) || 0) * 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clean phone number by removing dashes and spaces
|
||||||
|
*/
|
||||||
|
private cleanPhoneNumber(phone: string): string {
|
||||||
|
return phone.replace(/[-\s]/g, "");
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,419 @@
|
|||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { PrismaService } from "@bff/infra/database/prisma.service.js";
|
||||||
|
import { SftpClientService } from "@bff/integrations/sftp/sftp-client.service.js";
|
||||||
|
import { SimValidationService } from "../sim-validation.service.js";
|
||||||
|
import { SimCallHistoryParserService } from "./sim-call-history-parser.service.js";
|
||||||
|
import { SimCallHistoryFormatterService } from "./sim-call-history-formatter.service.js";
|
||||||
|
import type {
|
||||||
|
SimDomesticCallHistoryResponse,
|
||||||
|
SimInternationalCallHistoryResponse,
|
||||||
|
SimSmsHistoryResponse,
|
||||||
|
} from "@customer-portal/domain/sim";
|
||||||
|
|
||||||
|
// Re-export types for consumers
|
||||||
|
export type {
|
||||||
|
DomesticCallRecord,
|
||||||
|
InternationalCallRecord,
|
||||||
|
SmsRecord,
|
||||||
|
} from "./sim-call-history-parser.service.js";
|
||||||
|
|
||||||
|
// SmsType enum to match Prisma schema
|
||||||
|
type SmsType = "DOMESTIC" | "INTERNATIONAL";
|
||||||
|
|
||||||
|
export interface CallHistoryPagination {
|
||||||
|
page: number;
|
||||||
|
limit: number;
|
||||||
|
total: number;
|
||||||
|
totalPages: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service for managing SIM call history data.
|
||||||
|
* Coordinates importing data from SFTP and querying stored history.
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SimCallHistoryService {
|
||||||
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly sftp: SftpClientService,
|
||||||
|
private readonly simValidation: SimValidationService,
|
||||||
|
private readonly parser: SimCallHistoryParserService,
|
||||||
|
private readonly formatter: SimCallHistoryFormatterService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Import call history from SFTP for a specific month
|
||||||
|
*/
|
||||||
|
async importCallHistory(yearMonth: string): Promise<{
|
||||||
|
domestic: number;
|
||||||
|
international: number;
|
||||||
|
sms: number;
|
||||||
|
}> {
|
||||||
|
const month = this.formatter.normalizeMonth(yearMonth);
|
||||||
|
|
||||||
|
this.logger.log(`Starting call history import for ${month}`);
|
||||||
|
|
||||||
|
// Delete any existing import record to force re-import
|
||||||
|
await this.prisma.simHistoryImport.deleteMany({
|
||||||
|
where: { month },
|
||||||
|
});
|
||||||
|
this.logger.log(`Cleared existing import record for ${month}`);
|
||||||
|
|
||||||
|
let domesticCount = 0;
|
||||||
|
let internationalCount = 0;
|
||||||
|
let smsCount = 0;
|
||||||
|
|
||||||
|
// Import talk detail (calls)
|
||||||
|
try {
|
||||||
|
const talkContent = await this.sftp.downloadTalkDetail(yearMonth);
|
||||||
|
const parsed = this.parser.parseTalkDetailCsv(talkContent, month);
|
||||||
|
|
||||||
|
this.logger.log(`Parsed talk detail`, {
|
||||||
|
domestic: parsed.domestic.length,
|
||||||
|
international: parsed.international.length,
|
||||||
|
skipped: parsed.skipped,
|
||||||
|
errors: parsed.errors,
|
||||||
|
});
|
||||||
|
|
||||||
|
domesticCount = await this.processInBatches(
|
||||||
|
parsed.domestic,
|
||||||
|
50,
|
||||||
|
async record =>
|
||||||
|
this.prisma.simCallHistoryDomestic.upsert({
|
||||||
|
where: {
|
||||||
|
account_callDate_callTime_calledTo: {
|
||||||
|
account: record.account,
|
||||||
|
callDate: record.callDate,
|
||||||
|
callTime: record.callTime,
|
||||||
|
calledTo: record.calledTo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
location: record.location,
|
||||||
|
durationSec: record.durationSec,
|
||||||
|
chargeYen: record.chargeYen,
|
||||||
|
},
|
||||||
|
create: record,
|
||||||
|
}),
|
||||||
|
(record, error) =>
|
||||||
|
this.logger.warn("Failed to store domestic call record", { record, error })
|
||||||
|
);
|
||||||
|
|
||||||
|
internationalCount = await this.processInBatches(
|
||||||
|
parsed.international,
|
||||||
|
50,
|
||||||
|
async record =>
|
||||||
|
this.prisma.simCallHistoryInternational.upsert({
|
||||||
|
where: {
|
||||||
|
account_callDate_startTime_calledTo: {
|
||||||
|
account: record.account,
|
||||||
|
callDate: record.callDate,
|
||||||
|
startTime: record.startTime,
|
||||||
|
calledTo: record.calledTo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
stopTime: record.stopTime,
|
||||||
|
country: record.country,
|
||||||
|
durationSec: record.durationSec,
|
||||||
|
chargeYen: record.chargeYen,
|
||||||
|
},
|
||||||
|
create: record,
|
||||||
|
}),
|
||||||
|
(record, error) =>
|
||||||
|
this.logger.warn("Failed to store international call record", { record, error })
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Imported ${domesticCount} domestic and ${internationalCount} international calls`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to import talk detail`, { error, yearMonth });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import SMS detail
|
||||||
|
try {
|
||||||
|
const smsContent = await this.sftp.downloadSmsDetail(yearMonth);
|
||||||
|
const parsed = this.parser.parseSmsDetailCsv(smsContent, month);
|
||||||
|
|
||||||
|
this.logger.log(`Parsed SMS detail`, {
|
||||||
|
records: parsed.records.length,
|
||||||
|
skipped: parsed.skipped,
|
||||||
|
errors: parsed.errors,
|
||||||
|
});
|
||||||
|
|
||||||
|
smsCount = await this.processInBatches(
|
||||||
|
parsed.records,
|
||||||
|
50,
|
||||||
|
async record =>
|
||||||
|
this.prisma.simSmsHistory.upsert({
|
||||||
|
where: {
|
||||||
|
account_smsDate_smsTime_sentTo: {
|
||||||
|
account: record.account,
|
||||||
|
smsDate: record.smsDate,
|
||||||
|
smsTime: record.smsTime,
|
||||||
|
sentTo: record.sentTo,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
smsType: record.smsType,
|
||||||
|
},
|
||||||
|
create: record,
|
||||||
|
}),
|
||||||
|
(record, error) => this.logger.warn("Failed to store SMS record", { record, error })
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Imported ${smsCount} SMS records`);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to import SMS detail`, { error, yearMonth });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record the import
|
||||||
|
await this.prisma.simHistoryImport.upsert({
|
||||||
|
where: { month },
|
||||||
|
update: {
|
||||||
|
talkFile: this.sftp.getTalkDetailFileName(yearMonth),
|
||||||
|
smsFile: this.sftp.getSmsDetailFileName(yearMonth),
|
||||||
|
talkRecords: domesticCount + internationalCount,
|
||||||
|
smsRecords: smsCount,
|
||||||
|
status: "completed",
|
||||||
|
importedAt: new Date(),
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
month,
|
||||||
|
talkFile: this.sftp.getTalkDetailFileName(yearMonth),
|
||||||
|
smsFile: this.sftp.getSmsDetailFileName(yearMonth),
|
||||||
|
talkRecords: domesticCount + internationalCount,
|
||||||
|
smsRecords: smsCount,
|
||||||
|
status: "completed",
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return { domestic: domesticCount, international: internationalCount, sms: smsCount };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get domestic call history for a user's SIM
|
||||||
|
*/
|
||||||
|
async getDomesticCallHistory(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
month?: string,
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 50
|
||||||
|
): Promise<SimDomesticCallHistoryResponse> {
|
||||||
|
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
// Dev/testing mode: call history data is currently sourced from a fixed account.
|
||||||
|
// TODO: Replace with the validated subscription account once call history data is available per user.
|
||||||
|
const account = "08077052946";
|
||||||
|
|
||||||
|
const targetMonth = month || this.formatter.getDefaultMonth();
|
||||||
|
|
||||||
|
const [calls, total] = await Promise.all([
|
||||||
|
this.prisma.simCallHistoryDomestic.findMany({
|
||||||
|
where: { account, month: targetMonth },
|
||||||
|
orderBy: [{ callDate: "desc" }, { callTime: "desc" }],
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
this.prisma.simCallHistoryDomestic.count({
|
||||||
|
where: { account, month: targetMonth },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
calls: calls.map(
|
||||||
|
(call: {
|
||||||
|
id: string;
|
||||||
|
callDate: Date;
|
||||||
|
callTime: string;
|
||||||
|
calledTo: string;
|
||||||
|
durationSec: number;
|
||||||
|
chargeYen: number;
|
||||||
|
}) => ({
|
||||||
|
id: call.id,
|
||||||
|
date: call.callDate.toISOString().split("T")[0] ?? "",
|
||||||
|
time: this.formatter.formatTime(call.callTime),
|
||||||
|
calledTo: this.formatter.formatPhoneNumber(call.calledTo),
|
||||||
|
callLength: this.formatter.formatDuration(call.durationSec),
|
||||||
|
callCharge: call.chargeYen,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
month: targetMonth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get international call history for a user's SIM
|
||||||
|
*/
|
||||||
|
async getInternationalCallHistory(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
month?: string,
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 50
|
||||||
|
): Promise<SimInternationalCallHistoryResponse> {
|
||||||
|
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
const account = "08077052946";
|
||||||
|
|
||||||
|
const targetMonth = month || this.formatter.getDefaultMonth();
|
||||||
|
|
||||||
|
const [calls, total] = await Promise.all([
|
||||||
|
this.prisma.simCallHistoryInternational.findMany({
|
||||||
|
where: { account, month: targetMonth },
|
||||||
|
orderBy: [{ callDate: "desc" }, { startTime: "desc" }],
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
this.prisma.simCallHistoryInternational.count({
|
||||||
|
where: { account, month: targetMonth },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
calls: calls.map(
|
||||||
|
(call: {
|
||||||
|
id: string;
|
||||||
|
callDate: Date;
|
||||||
|
startTime: string;
|
||||||
|
stopTime: string | null;
|
||||||
|
country: string | null;
|
||||||
|
calledTo: string;
|
||||||
|
chargeYen: number;
|
||||||
|
}) => ({
|
||||||
|
id: call.id,
|
||||||
|
date: call.callDate.toISOString().split("T")[0] ?? "",
|
||||||
|
startTime: this.formatter.formatTime(call.startTime),
|
||||||
|
stopTime: call.stopTime ? this.formatter.formatTime(call.stopTime) : null,
|
||||||
|
country: call.country,
|
||||||
|
calledTo: this.formatter.formatPhoneNumber(call.calledTo),
|
||||||
|
callCharge: call.chargeYen,
|
||||||
|
})
|
||||||
|
),
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
month: targetMonth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get SMS history for a user's SIM
|
||||||
|
*/
|
||||||
|
async getSmsHistory(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
month?: string,
|
||||||
|
page: number = 1,
|
||||||
|
limit: number = 50
|
||||||
|
): Promise<SimSmsHistoryResponse> {
|
||||||
|
await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
||||||
|
const account = "08077052946";
|
||||||
|
|
||||||
|
const targetMonth = month || this.formatter.getDefaultMonth();
|
||||||
|
|
||||||
|
const [messages, total] = await Promise.all([
|
||||||
|
this.prisma.simSmsHistory.findMany({
|
||||||
|
where: { account, month: targetMonth },
|
||||||
|
orderBy: [{ smsDate: "desc" }, { smsTime: "desc" }],
|
||||||
|
skip: (page - 1) * limit,
|
||||||
|
take: limit,
|
||||||
|
}),
|
||||||
|
this.prisma.simSmsHistory.count({
|
||||||
|
where: { account, month: targetMonth },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages: messages.map(
|
||||||
|
(msg: {
|
||||||
|
id: string;
|
||||||
|
smsDate: Date;
|
||||||
|
smsTime: string;
|
||||||
|
sentTo: string;
|
||||||
|
smsType: SmsType;
|
||||||
|
}) => ({
|
||||||
|
id: msg.id,
|
||||||
|
date: msg.smsDate.toISOString().split("T")[0] ?? "",
|
||||||
|
time: this.formatter.formatTime(msg.smsTime),
|
||||||
|
sentTo: this.formatter.formatPhoneNumber(msg.sentTo),
|
||||||
|
type: msg.smsType === "INTERNATIONAL" ? "International SMS" : "SMS",
|
||||||
|
})
|
||||||
|
),
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
total,
|
||||||
|
totalPages: Math.ceil(total / limit),
|
||||||
|
},
|
||||||
|
month: targetMonth,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get available months for history
|
||||||
|
*/
|
||||||
|
async getAvailableMonths(): Promise<string[]> {
|
||||||
|
this.logger.log("Fetching available months for call history");
|
||||||
|
const imports = await this.prisma.simHistoryImport.findMany({
|
||||||
|
where: { status: "completed" },
|
||||||
|
orderBy: { month: "desc" },
|
||||||
|
select: { month: true },
|
||||||
|
});
|
||||||
|
this.logger.log(`Found ${imports.length} completed imports`, { months: imports });
|
||||||
|
return imports.map((i: { month: string }) => i.month);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* List available files on SFTP server
|
||||||
|
*/
|
||||||
|
async listSftpFiles(path: string = "/"): Promise<string[]> {
|
||||||
|
try {
|
||||||
|
return await this.sftp.listFiles(path);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Failed to list SFTP files", { error, path });
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Process records in batches with error handling
|
||||||
|
*/
|
||||||
|
private async processInBatches<T>(
|
||||||
|
records: T[],
|
||||||
|
batchSize: number,
|
||||||
|
handler: (record: T) => Promise<unknown>,
|
||||||
|
onError: (record: T, error: unknown) => void
|
||||||
|
): Promise<number> {
|
||||||
|
let successCount = 0;
|
||||||
|
|
||||||
|
for (let i = 0; i < records.length; i += batchSize) {
|
||||||
|
const batch = records.slice(i, i + batchSize);
|
||||||
|
const results = await Promise.allSettled(batch.map(async record => handler(record)));
|
||||||
|
|
||||||
|
for (const [index, result] of results.entries()) {
|
||||||
|
if (result.status === "fulfilled") {
|
||||||
|
successCount++;
|
||||||
|
} else {
|
||||||
|
const record = batch[index];
|
||||||
|
if (record !== undefined) {
|
||||||
|
onError(record, result.reason);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return successCount;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,345 @@
|
|||||||
|
import { Injectable, Inject } from "@nestjs/common";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { ConfigService } from "@nestjs/config";
|
||||||
|
import { EmailService } from "@bff/infra/email/email.service.js";
|
||||||
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
|
import type { SimNotificationContext } from "../../interfaces/sim-base.interface.js";
|
||||||
|
|
||||||
|
const ADMIN_EMAIL = "info@asolutions.co.jp";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API call log structure for notification emails
|
||||||
|
*/
|
||||||
|
export interface ApiCallLog {
|
||||||
|
url: string;
|
||||||
|
senddata?: Record<string, unknown> | string;
|
||||||
|
json?: Record<string, unknown> | string;
|
||||||
|
result: Record<string, unknown> | string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unified SIM notification service.
|
||||||
|
* Handles all SIM-related email notifications including:
|
||||||
|
* - Internal action notifications (success/error alerts)
|
||||||
|
* - API results notifications to admin
|
||||||
|
* - Customer-facing emails (eSIM reissue, cancellation confirmations)
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SimNotificationService {
|
||||||
|
constructor(
|
||||||
|
@Inject(Logger) private readonly logger: Logger,
|
||||||
|
private readonly email: EmailService,
|
||||||
|
private readonly configService: ConfigService
|
||||||
|
) {}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Internal Action Notifications
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send notification for SIM actions to configured alert email
|
||||||
|
*/
|
||||||
|
async notifySimAction(
|
||||||
|
action: string,
|
||||||
|
status: "SUCCESS" | "ERROR",
|
||||||
|
context: SimNotificationContext
|
||||||
|
): Promise<void> {
|
||||||
|
const subject = `[SIM ACTION] ${action} - ${status}`;
|
||||||
|
const toAddress = this.configService.get<string>("SIM_ALERT_EMAIL_TO");
|
||||||
|
const fromAddress = this.configService.get<string>("SIM_ALERT_EMAIL_FROM");
|
||||||
|
|
||||||
|
if (!toAddress || !fromAddress) {
|
||||||
|
this.logger.debug("SIM action notification skipped: email config missing", {
|
||||||
|
action,
|
||||||
|
status,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const publicContext = this.redactSensitiveFields(context);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const lines: string[] = [
|
||||||
|
`Action: ${action}`,
|
||||||
|
`Result: ${status}`,
|
||||||
|
`Timestamp: ${new Date().toISOString()}`,
|
||||||
|
"",
|
||||||
|
"Context:",
|
||||||
|
JSON.stringify(publicContext, null, 2),
|
||||||
|
];
|
||||||
|
await this.email.sendEmail({
|
||||||
|
to: toAddress,
|
||||||
|
from: fromAddress,
|
||||||
|
subject,
|
||||||
|
text: lines.join("\n"),
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn("Failed to send SIM action notification email", {
|
||||||
|
action,
|
||||||
|
status,
|
||||||
|
error: extractErrorMessage(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// API Results Notifications (Admin)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send API results notification email to admin
|
||||||
|
*/
|
||||||
|
async sendApiResultsEmail(
|
||||||
|
subject: string,
|
||||||
|
apiCalls: ApiCallLog[],
|
||||||
|
additionalInfo?: string
|
||||||
|
): Promise<void> {
|
||||||
|
try {
|
||||||
|
const lines: string[] = [];
|
||||||
|
|
||||||
|
for (const call of apiCalls) {
|
||||||
|
lines.push(`url: ${call.url}`);
|
||||||
|
lines.push("");
|
||||||
|
|
||||||
|
if (call.senddata) {
|
||||||
|
const senddataStr =
|
||||||
|
typeof call.senddata === "string"
|
||||||
|
? call.senddata
|
||||||
|
: JSON.stringify(call.senddata, null, 2);
|
||||||
|
lines.push(`senddata: ${senddataStr}`);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (call.json) {
|
||||||
|
const jsonStr =
|
||||||
|
typeof call.json === "string" ? call.json : JSON.stringify(call.json, null, 2);
|
||||||
|
lines.push(`json: ${jsonStr}`);
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
const resultStr =
|
||||||
|
typeof call.result === "string" ? call.result : JSON.stringify(call.result, null, 2);
|
||||||
|
lines.push(`result: ${resultStr}`);
|
||||||
|
lines.push("");
|
||||||
|
lines.push("---");
|
||||||
|
lines.push("");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (additionalInfo) {
|
||||||
|
lines.push(additionalInfo);
|
||||||
|
}
|
||||||
|
|
||||||
|
await this.email.sendEmail({
|
||||||
|
to: ADMIN_EMAIL,
|
||||||
|
from: ADMIN_EMAIL,
|
||||||
|
subject,
|
||||||
|
text: lines.join("\n"),
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("Sent API results notification email", {
|
||||||
|
subject,
|
||||||
|
to: ADMIN_EMAIL,
|
||||||
|
callCount: apiCalls.length,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn("Failed to send API results notification email", {
|
||||||
|
subject,
|
||||||
|
error: extractErrorMessage(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Customer Notifications
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Send customer notification email
|
||||||
|
*/
|
||||||
|
async sendCustomerEmail(to: string, subject: string, body: string): Promise<void> {
|
||||||
|
try {
|
||||||
|
await this.email.sendEmail({
|
||||||
|
to,
|
||||||
|
from: ADMIN_EMAIL,
|
||||||
|
subject,
|
||||||
|
text: body,
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log("Sent customer notification email", {
|
||||||
|
subject,
|
||||||
|
to,
|
||||||
|
});
|
||||||
|
} catch (err) {
|
||||||
|
this.logger.warn("Failed to send customer notification email", {
|
||||||
|
subject,
|
||||||
|
to,
|
||||||
|
error: extractErrorMessage(err),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Email Body Builders
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build eSIM reissue customer email body
|
||||||
|
*/
|
||||||
|
buildEsimReissueEmail(customerName: string, simNumber: string, newEid: string): string {
|
||||||
|
return `Dear ${customerName},
|
||||||
|
|
||||||
|
This is to confirm that your request to re-issue the SIM card ${simNumber}
|
||||||
|
to the EID=${newEid} has been accepted.
|
||||||
|
|
||||||
|
Please download the SIM plan, then follow the instructions to install the APN profile.
|
||||||
|
|
||||||
|
eSIM plan download: https://www.asolutions.co.jp/uploads/pdf/esim.pdf
|
||||||
|
APN profile instructions: https://www.asolutions.co.jp/sim-card/
|
||||||
|
|
||||||
|
With best regards,
|
||||||
|
Assist Solutions Customer Support
|
||||||
|
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
|
||||||
|
Email: ${ADMIN_EMAIL}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build physical SIM reissue customer email body
|
||||||
|
*/
|
||||||
|
buildPhysicalSimReissueEmail(customerName: string, simNumber: string): string {
|
||||||
|
return `Dear ${customerName},
|
||||||
|
|
||||||
|
This is to confirm that your request to re-issue the SIM card ${simNumber}
|
||||||
|
as a physical SIM has been accepted.
|
||||||
|
|
||||||
|
You will be contacted by us again as soon as details about the shipping
|
||||||
|
schedule can be disclosed (typically in 3-5 business days).
|
||||||
|
|
||||||
|
With best regards,
|
||||||
|
Assist Solutions Customer Support
|
||||||
|
TEL: 0120-660-470 (Mon-Fri / 10AM-6PM)
|
||||||
|
Email: ${ADMIN_EMAIL}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Build cancellation notification email body for admin
|
||||||
|
*/
|
||||||
|
buildCancellationAdminEmail(params: {
|
||||||
|
customerName: string;
|
||||||
|
simNumber: string;
|
||||||
|
serialNumber?: string;
|
||||||
|
cancellationMonth: string;
|
||||||
|
registeredEmail: string;
|
||||||
|
comments?: string;
|
||||||
|
}): string {
|
||||||
|
return `The following SONIXNET SIM cancellation has been requested.
|
||||||
|
|
||||||
|
Customer name: ${params.customerName}
|
||||||
|
SIM #: ${params.simNumber}
|
||||||
|
Serial #: ${params.serialNumber || "N/A"}
|
||||||
|
Cancellation month: ${params.cancellationMonth}
|
||||||
|
Registered email address: ${params.registeredEmail}
|
||||||
|
Comments: ${params.comments || "N/A"}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Message Utilities
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Convert technical errors to user-friendly messages for SIM operations
|
||||||
|
*/
|
||||||
|
getUserFriendlySimError(technicalError: string): string {
|
||||||
|
if (!technicalError) {
|
||||||
|
return "SIM operation failed. Please try again or contact support.";
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorLower = technicalError.toLowerCase();
|
||||||
|
|
||||||
|
// Freebit API errors
|
||||||
|
if (errorLower.includes("api error: ng") || errorLower.includes("account not found")) {
|
||||||
|
return "SIM account not found. Please contact support to verify your SIM configuration.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorLower.includes("authentication failed") || errorLower.includes("auth")) {
|
||||||
|
return "SIM service is temporarily unavailable. Please try again later.";
|
||||||
|
}
|
||||||
|
|
||||||
|
if (errorLower.includes("timeout") || errorLower.includes("network")) {
|
||||||
|
return "SIM service request timed out. Please try again.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// WHMCS errors
|
||||||
|
if (errorLower.includes("invalid permissions") || errorLower.includes("not allowed")) {
|
||||||
|
return "SIM service is temporarily unavailable. Please contact support for assistance.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Generic errors
|
||||||
|
if (errorLower.includes("failed") || errorLower.includes("error")) {
|
||||||
|
return "SIM operation failed. Please try again or contact support.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Default fallback
|
||||||
|
return "SIM operation failed. Please try again or contact support.";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Action Runner (for wrapping operations with notifications)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Run an operation with automatic success/error notifications.
|
||||||
|
* Replaces the separate SimActionRunnerService.
|
||||||
|
*/
|
||||||
|
async runWithNotification<T>(
|
||||||
|
action: string,
|
||||||
|
options: {
|
||||||
|
baseContext: SimNotificationContext;
|
||||||
|
enrichSuccess?: (result: T) => Partial<SimNotificationContext>;
|
||||||
|
enrichError?: (error: unknown) => Partial<SimNotificationContext>;
|
||||||
|
},
|
||||||
|
handler: () => Promise<T>
|
||||||
|
): Promise<T> {
|
||||||
|
try {
|
||||||
|
const result = await handler();
|
||||||
|
const successContext = {
|
||||||
|
...options.baseContext,
|
||||||
|
...(options.enrichSuccess ? options.enrichSuccess(result) : {}),
|
||||||
|
};
|
||||||
|
await this.notifySimAction(action, "SUCCESS", successContext);
|
||||||
|
return result;
|
||||||
|
} catch (error) {
|
||||||
|
const errorContext = {
|
||||||
|
...options.baseContext,
|
||||||
|
error: extractErrorMessage(error),
|
||||||
|
...(options.enrichError ? options.enrichError(error) : {}),
|
||||||
|
};
|
||||||
|
await this.notifySimAction(action, "ERROR", errorContext);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Private Helpers
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Redact sensitive information from notification context
|
||||||
|
*/
|
||||||
|
private redactSensitiveFields(context: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
const sanitized: Record<string, unknown> = {};
|
||||||
|
for (const [key, value] of Object.entries(context)) {
|
||||||
|
if (typeof key === "string" && key.toLowerCase().includes("password")) {
|
||||||
|
sanitized[key] = "[REDACTED]";
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof value === "string" && value.length > 200) {
|
||||||
|
sanitized[key] = `${value.slice(0, 200)}…`;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
sanitized[key] = value;
|
||||||
|
}
|
||||||
|
return sanitized;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,71 @@
|
|||||||
|
import { BadRequestException, Injectable } from "@nestjs/common";
|
||||||
|
|
||||||
|
type ScheduleResolution = {
|
||||||
|
date: string;
|
||||||
|
source: "user" | "auto";
|
||||||
|
};
|
||||||
|
|
||||||
|
@Injectable()
|
||||||
|
export class SimScheduleService {
|
||||||
|
private static readonly DATE_REGEX = /^\d{8}$/;
|
||||||
|
|
||||||
|
ensureYyyyMmDd(value: string | undefined, fieldName = "date"): string {
|
||||||
|
if (!value) {
|
||||||
|
throw new BadRequestException(`${fieldName} is required in YYYYMMDD format`);
|
||||||
|
}
|
||||||
|
if (!SimScheduleService.DATE_REGEX.test(value)) {
|
||||||
|
throw new BadRequestException(`${fieldName} must be in YYYYMMDD format`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
validateOptionalYyyyMmDd(value: string | undefined, fieldName = "date"): string | undefined {
|
||||||
|
if (value == null) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!SimScheduleService.DATE_REGEX.test(value)) {
|
||||||
|
throw new BadRequestException(`${fieldName} must be in YYYYMMDD format`);
|
||||||
|
}
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
resolveScheduledDate(value?: string, fieldName = "scheduledAt"): ScheduleResolution {
|
||||||
|
if (value) {
|
||||||
|
return {
|
||||||
|
date: this.ensureYyyyMmDd(value, fieldName),
|
||||||
|
source: "user",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
date: this.firstDayOfNextMonthYyyyMmDd(),
|
||||||
|
source: "auto",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
firstDayOfNextMonth(): Date {
|
||||||
|
const nextMonth = new Date();
|
||||||
|
nextMonth.setHours(0, 0, 0, 0);
|
||||||
|
nextMonth.setMonth(nextMonth.getMonth() + 1);
|
||||||
|
nextMonth.setDate(1);
|
||||||
|
return nextMonth;
|
||||||
|
}
|
||||||
|
|
||||||
|
firstDayOfNextMonthYyyyMmDd(): string {
|
||||||
|
return this.formatYyyyMmDd(this.firstDayOfNextMonth());
|
||||||
|
}
|
||||||
|
|
||||||
|
firstDayOfNextMonthIsoDate(): string {
|
||||||
|
return this.formatIsoDate(this.firstDayOfNextMonth());
|
||||||
|
}
|
||||||
|
|
||||||
|
formatYyyyMmDd(date: Date): string {
|
||||||
|
const year = date.getFullYear();
|
||||||
|
const month = String(date.getMonth() + 1).padStart(2, "0");
|
||||||
|
const day = String(date.getDate()).padStart(2, "0");
|
||||||
|
return `${year}${month}${day}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
formatIsoDate(date: Date): string {
|
||||||
|
return date.toISOString().split("T")[0] ?? "";
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,472 @@
|
|||||||
|
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
||||||
|
import {
|
||||||
|
subscriptionListSchema,
|
||||||
|
subscriptionStatusSchema,
|
||||||
|
subscriptionStatsSchema,
|
||||||
|
} from "@customer-portal/domain/subscriptions";
|
||||||
|
import type {
|
||||||
|
Subscription,
|
||||||
|
SubscriptionList,
|
||||||
|
SubscriptionStatus,
|
||||||
|
} from "@customer-portal/domain/subscriptions";
|
||||||
|
import type { Invoice, InvoiceItem, InvoiceList } from "@customer-portal/domain/billing";
|
||||||
|
import { WhmcsCacheService } from "@bff/integrations/whmcs/cache/whmcs-cache.service.js";
|
||||||
|
import { WhmcsConnectionFacade } from "@bff/integrations/whmcs/facades/whmcs.facade.js";
|
||||||
|
import { WhmcsClientService } from "@bff/integrations/whmcs/services/whmcs-client.service.js";
|
||||||
|
import { WhmcsInvoiceService } from "@bff/integrations/whmcs/services/whmcs-invoice.service.js";
|
||||||
|
import { WhmcsSubscriptionService } from "@bff/integrations/whmcs/services/whmcs-subscription.service.js";
|
||||||
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
|
||||||
|
import { Logger } from "nestjs-pino";
|
||||||
|
import { withErrorHandling } from "@bff/core/utils/error-handler.util.js";
|
||||||
|
import { extractErrorMessage } from "@bff/core/utils/error.util.js";
|
||||||
|
|
||||||
|
export interface GetSubscriptionsOptions {
|
||||||
|
status?: SubscriptionStatus;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Subscriptions Orchestrator
|
||||||
|
*
|
||||||
|
* Coordinates subscription management operations across multiple
|
||||||
|
* integration services (WHMCS, Salesforce).
|
||||||
|
*/
|
||||||
|
@Injectable()
|
||||||
|
export class SubscriptionsOrchestrator {
|
||||||
|
constructor(
|
||||||
|
private readonly whmcsSubscriptionService: WhmcsSubscriptionService,
|
||||||
|
private readonly whmcsInvoiceService: WhmcsInvoiceService,
|
||||||
|
private readonly whmcsClientService: WhmcsClientService,
|
||||||
|
private readonly whmcsConnectionService: WhmcsConnectionFacade,
|
||||||
|
private readonly cacheService: WhmcsCacheService,
|
||||||
|
private readonly mappingsService: MappingsService,
|
||||||
|
@Inject(Logger) private readonly logger: Logger
|
||||||
|
) {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get all subscriptions for a user
|
||||||
|
*/
|
||||||
|
async getSubscriptions(
|
||||||
|
userId: string,
|
||||||
|
options: GetSubscriptionsOptions = {}
|
||||||
|
): Promise<SubscriptionList> {
|
||||||
|
const { status } = options;
|
||||||
|
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
|
||||||
|
|
||||||
|
return withErrorHandling(
|
||||||
|
async () => {
|
||||||
|
const subscriptionList = await this.whmcsSubscriptionService.getSubscriptions(
|
||||||
|
whmcsClientId,
|
||||||
|
userId,
|
||||||
|
status === undefined ? {} : { status }
|
||||||
|
);
|
||||||
|
|
||||||
|
const parsed = subscriptionListSchema.parse(subscriptionList);
|
||||||
|
|
||||||
|
let subscriptions = parsed.subscriptions;
|
||||||
|
if (status) {
|
||||||
|
const normalizedStatus = subscriptionStatusSchema.parse(status);
|
||||||
|
subscriptions = subscriptions.filter(sub => sub.status === normalizedStatus);
|
||||||
|
}
|
||||||
|
|
||||||
|
return subscriptionListSchema.parse({
|
||||||
|
subscriptions,
|
||||||
|
totalCount: subscriptions.length,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
{
|
||||||
|
context: `Get subscriptions for user ${userId}`,
|
||||||
|
fallbackMessage: "Failed to retrieve subscriptions",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get individual subscription by ID
|
||||||
|
*/
|
||||||
|
async getSubscriptionById(userId: string, subscriptionId: number): Promise<Subscription> {
|
||||||
|
// Validate subscription ID
|
||||||
|
if (!subscriptionId || subscriptionId < 1) {
|
||||||
|
throw new BadRequestException("Subscription ID must be a positive number");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get WHMCS client ID from user mapping
|
||||||
|
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
|
||||||
|
|
||||||
|
return withErrorHandling(
|
||||||
|
async () => {
|
||||||
|
const subscription = await this.whmcsSubscriptionService.getSubscriptionById(
|
||||||
|
whmcsClientId,
|
||||||
|
userId,
|
||||||
|
subscriptionId
|
||||||
|
);
|
||||||
|
|
||||||
|
this.logger.log(`Retrieved subscription ${subscriptionId} for user ${userId}`, {
|
||||||
|
productName: subscription.productName,
|
||||||
|
status: subscription.status,
|
||||||
|
amount: subscription.amount,
|
||||||
|
currency: subscription.currency,
|
||||||
|
});
|
||||||
|
|
||||||
|
return subscription;
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
{
|
||||||
|
context: `Get subscription ${subscriptionId} for user ${userId}`,
|
||||||
|
fallbackMessage: "Failed to retrieve subscription",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get active subscriptions for a user
|
||||||
|
*/
|
||||||
|
async getActiveSubscriptions(userId: string): Promise<Subscription[]> {
|
||||||
|
try {
|
||||||
|
const subscriptionList = await this.getSubscriptions(userId, {
|
||||||
|
status: "Active",
|
||||||
|
});
|
||||||
|
return subscriptionList.subscriptions;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to get active subscriptions for user ${userId}`, {
|
||||||
|
error: extractErrorMessage(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subscriptions by status
|
||||||
|
*/
|
||||||
|
async getSubscriptionsByStatus(
|
||||||
|
userId: string,
|
||||||
|
status: SubscriptionStatus
|
||||||
|
): Promise<Subscription[]> {
|
||||||
|
try {
|
||||||
|
const normalizedStatus = subscriptionStatusSchema.parse(status);
|
||||||
|
const subscriptionList = await this.getSubscriptions(userId, { status: normalizedStatus });
|
||||||
|
return subscriptionList.subscriptions;
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to get ${status} subscriptions for user ${userId}`, {
|
||||||
|
error: extractErrorMessage(error),
|
||||||
|
});
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subscription statistics for a user
|
||||||
|
*/
|
||||||
|
async getSubscriptionStats(userId: string): Promise<{
|
||||||
|
total: number;
|
||||||
|
active: number;
|
||||||
|
completed: number;
|
||||||
|
cancelled: number;
|
||||||
|
}> {
|
||||||
|
return withErrorHandling(
|
||||||
|
async () => {
|
||||||
|
const subscriptionList = await this.getSubscriptions(userId);
|
||||||
|
const subscriptions: Subscription[] = subscriptionList.subscriptions;
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: subscriptions.length,
|
||||||
|
active: subscriptions.filter(s => s.status === "Active").length,
|
||||||
|
completed: subscriptions.filter(s => s.status === "Completed").length,
|
||||||
|
cancelled: subscriptions.filter(s => s.status === "Cancelled").length,
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(`Generated subscription stats for user ${userId}`, stats);
|
||||||
|
|
||||||
|
return subscriptionStatsSchema.parse(stats);
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
{
|
||||||
|
context: `Generate subscription stats for user ${userId}`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get subscriptions expiring soon (within next 30 days)
|
||||||
|
*/
|
||||||
|
async getExpiringSoon(userId: string, days: number = 30): Promise<Subscription[]> {
|
||||||
|
return withErrorHandling(
|
||||||
|
async () => {
|
||||||
|
const subscriptionList = await this.getSubscriptions(userId);
|
||||||
|
const subscriptions: Subscription[] = subscriptionList.subscriptions;
|
||||||
|
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() + days);
|
||||||
|
|
||||||
|
const expiringSoon = subscriptions.filter(subscription => {
|
||||||
|
if (!subscription.nextDue || subscription.status !== "Active") {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
const nextDueDate = new Date(subscription.nextDue);
|
||||||
|
return nextDueDate <= cutoffDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Found ${expiringSoon.length} subscriptions expiring within ${days} days for user ${userId}`
|
||||||
|
);
|
||||||
|
return expiringSoon;
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
{
|
||||||
|
context: `Get expiring subscriptions for user ${userId}`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get recent subscription activity (newly created or status changed)
|
||||||
|
*/
|
||||||
|
async getRecentActivity(userId: string, days: number = 30): Promise<Subscription[]> {
|
||||||
|
return withErrorHandling(
|
||||||
|
async () => {
|
||||||
|
const subscriptionList = await this.getSubscriptions(userId);
|
||||||
|
const subscriptions = subscriptionList.subscriptions;
|
||||||
|
|
||||||
|
const cutoffDate = new Date();
|
||||||
|
cutoffDate.setDate(cutoffDate.getDate() - days);
|
||||||
|
|
||||||
|
const recentActivity = subscriptions.filter((subscription: Subscription) => {
|
||||||
|
const registrationDate = new Date(subscription.registrationDate);
|
||||||
|
return registrationDate >= cutoffDate;
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Found ${recentActivity.length} recent subscription activities within ${days} days for user ${userId}`
|
||||||
|
);
|
||||||
|
return recentActivity;
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
{
|
||||||
|
context: `Get recent subscription activity for user ${userId}`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Search subscriptions by product name or domain
|
||||||
|
*/
|
||||||
|
async searchSubscriptions(userId: string, query: string): Promise<Subscription[]> {
|
||||||
|
if (!query || query.trim().length < 2) {
|
||||||
|
throw new BadRequestException("Search query must be at least 2 characters long");
|
||||||
|
}
|
||||||
|
|
||||||
|
return withErrorHandling(
|
||||||
|
async () => {
|
||||||
|
const subscriptionList = await this.getSubscriptions(userId);
|
||||||
|
const subscriptions = subscriptionList.subscriptions;
|
||||||
|
|
||||||
|
const searchTerm = query.toLowerCase().trim();
|
||||||
|
const matches = subscriptions.filter((subscription: Subscription) => {
|
||||||
|
const productName = subscription.productName.toLowerCase();
|
||||||
|
const domain = subscription.domain?.toLowerCase() || "";
|
||||||
|
|
||||||
|
return productName.includes(searchTerm) || domain.includes(searchTerm);
|
||||||
|
});
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Found ${matches.length} subscriptions matching query "${query}" for user ${userId}`
|
||||||
|
);
|
||||||
|
return matches;
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
{
|
||||||
|
context: `Search subscriptions for user ${userId}`,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Get invoices related to a specific subscription
|
||||||
|
*/
|
||||||
|
async getSubscriptionInvoices(
|
||||||
|
userId: string,
|
||||||
|
subscriptionId: number,
|
||||||
|
options: { page?: number; limit?: number } = {}
|
||||||
|
): Promise<InvoiceList> {
|
||||||
|
const { page = 1, limit = 10 } = options;
|
||||||
|
const batchSize = Math.min(100, Math.max(limit, 25));
|
||||||
|
|
||||||
|
return withErrorHandling(
|
||||||
|
async () => {
|
||||||
|
// Try page cache first
|
||||||
|
const cached = await this.cacheService.getSubscriptionInvoices(
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
page,
|
||||||
|
limit
|
||||||
|
);
|
||||||
|
if (cached) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Cache hit for subscription invoices: user ${userId}, subscription ${subscriptionId}`
|
||||||
|
);
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try full list cache to avoid rescanning all WHMCS invoices per page
|
||||||
|
const cachedAll = await this.cacheService.getSubscriptionInvoicesAll(
|
||||||
|
userId,
|
||||||
|
subscriptionId
|
||||||
|
);
|
||||||
|
if (cachedAll) {
|
||||||
|
const startIndex = (page - 1) * limit;
|
||||||
|
const endIndex = startIndex + limit;
|
||||||
|
const paginatedInvoices = cachedAll.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
const result: InvoiceList = {
|
||||||
|
invoices: paginatedInvoices,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
totalPages: cachedAll.length === 0 ? 0 : Math.ceil(cachedAll.length / limit),
|
||||||
|
totalItems: cachedAll.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await this.cacheService.setSubscriptionInvoices(
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
result
|
||||||
|
);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate subscription exists and belongs to user
|
||||||
|
await this.getSubscriptionById(userId, subscriptionId);
|
||||||
|
|
||||||
|
// Get WHMCS client ID from user mapping
|
||||||
|
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
|
||||||
|
|
||||||
|
const relatedInvoices: Invoice[] = [];
|
||||||
|
let currentPage = 1;
|
||||||
|
let totalPages = 1;
|
||||||
|
|
||||||
|
do {
|
||||||
|
const invoiceBatch = await this.whmcsInvoiceService.getInvoicesWithItems(
|
||||||
|
whmcsClientId,
|
||||||
|
userId,
|
||||||
|
{ page: currentPage, limit: batchSize }
|
||||||
|
);
|
||||||
|
|
||||||
|
totalPages = invoiceBatch.pagination.totalPages;
|
||||||
|
|
||||||
|
for (const invoice of invoiceBatch.invoices) {
|
||||||
|
if (!invoice.items?.length) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const hasMatchingService = invoice.items.some(
|
||||||
|
(item: InvoiceItem) => item.serviceId === subscriptionId
|
||||||
|
);
|
||||||
|
|
||||||
|
if (hasMatchingService) {
|
||||||
|
relatedInvoices.push(invoice);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
currentPage += 1;
|
||||||
|
} while (currentPage <= totalPages);
|
||||||
|
|
||||||
|
// Apply pagination to filtered results
|
||||||
|
const startIndex = (page - 1) * limit;
|
||||||
|
const endIndex = startIndex + limit;
|
||||||
|
const paginatedInvoices = relatedInvoices.slice(startIndex, endIndex);
|
||||||
|
|
||||||
|
const result: InvoiceList = {
|
||||||
|
invoices: paginatedInvoices,
|
||||||
|
pagination: {
|
||||||
|
page,
|
||||||
|
totalPages:
|
||||||
|
relatedInvoices.length === 0 ? 0 : Math.ceil(relatedInvoices.length / limit),
|
||||||
|
totalItems: relatedInvoices.length,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Retrieved ${paginatedInvoices.length} invoices for subscription ${subscriptionId}`,
|
||||||
|
{
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
totalRelated: relatedInvoices.length,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
// Cache the result
|
||||||
|
await this.cacheService.setSubscriptionInvoices(
|
||||||
|
userId,
|
||||||
|
subscriptionId,
|
||||||
|
page,
|
||||||
|
limit,
|
||||||
|
result
|
||||||
|
);
|
||||||
|
await this.cacheService.setSubscriptionInvoicesAll(userId, subscriptionId, relatedInvoices);
|
||||||
|
|
||||||
|
return result;
|
||||||
|
},
|
||||||
|
this.logger,
|
||||||
|
{
|
||||||
|
context: `Get invoices for subscription ${subscriptionId}`,
|
||||||
|
fallbackMessage: "Failed to retrieve subscription invoices",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Invalidate subscription cache for a user
|
||||||
|
*/
|
||||||
|
async invalidateCache(userId: string, subscriptionId?: number): Promise<void> {
|
||||||
|
try {
|
||||||
|
if (subscriptionId) {
|
||||||
|
await this.whmcsSubscriptionService.invalidateSubscriptionCache(userId, subscriptionId);
|
||||||
|
} else {
|
||||||
|
await this.whmcsClientService.invalidateUserCache(userId);
|
||||||
|
}
|
||||||
|
|
||||||
|
this.logger.log(
|
||||||
|
`Invalidated subscription cache for user ${userId}${subscriptionId ? `, subscription ${subscriptionId}` : ""}`
|
||||||
|
);
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error(`Failed to invalidate subscription cache for user ${userId}`, {
|
||||||
|
error: extractErrorMessage(error),
|
||||||
|
subscriptionId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Health check for subscription service
|
||||||
|
*/
|
||||||
|
async healthCheck(): Promise<{ status: string; details: unknown }> {
|
||||||
|
try {
|
||||||
|
const whmcsHealthy = await this.whmcsConnectionService.healthCheck();
|
||||||
|
|
||||||
|
return {
|
||||||
|
status: whmcsHealthy ? "healthy" : "unhealthy",
|
||||||
|
details: {
|
||||||
|
whmcsApi: whmcsHealthy ? "connected" : "disconnected",
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
this.logger.error("Subscription service health check failed", {
|
||||||
|
error: extractErrorMessage(error),
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
status: "unhealthy",
|
||||||
|
details: {
|
||||||
|
error: extractErrorMessage(error),
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
x
Reference in New Issue
Block a user