- Deleted migration file that removed cached profile fields from the users table, centralizing profile data retrieval from WHMCS. - Updated CsrfMiddleware to include new public authentication endpoints for password reset, setting password, and WHMCS account linking. - Enhanced error handling in password and WHMCS linking workflows to provide clearer feedback on missing mappings and improve user experience. - Adjusted user creation and update methods in UsersFacade to handle cases where WHMCS mappings are not yet available, ensuring smoother account setup.
281 lines
9.0 KiB
TypeScript
281 lines
9.0 KiB
TypeScript
import { Injectable, Inject, BadRequestException } from "@nestjs/common";
|
|
import { Logger } from "nestjs-pino";
|
|
import { FreebitOrchestratorService } from "@bff/integrations/freebit/services/freebit-orchestrator.service";
|
|
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
|
import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
|
import { SimValidationService } from "./sim-validation.service";
|
|
import { SimNotificationService } from "./sim-notification.service";
|
|
import { getErrorMessage } from "@bff/core/utils/error.util";
|
|
import type { SimTopUpRequest } from "../types/sim-requests.types";
|
|
|
|
@Injectable()
|
|
export class SimTopUpService {
|
|
constructor(
|
|
private readonly freebitService: FreebitOrchestratorService,
|
|
private readonly whmcsService: WhmcsService,
|
|
private readonly mappingsService: MappingsService,
|
|
private readonly simValidation: SimValidationService,
|
|
private readonly simNotification: SimNotificationService,
|
|
@Inject(Logger) private readonly logger: Logger
|
|
) {}
|
|
|
|
/**
|
|
* Top up SIM data quota with payment processing
|
|
* Pricing: 1GB = 500 JPY
|
|
*/
|
|
async topUpSim(userId: string, subscriptionId: number, request: SimTopUpRequest): Promise<void> {
|
|
let account: string = "";
|
|
let costJpy = 0;
|
|
let currency = request.currency ?? "JPY";
|
|
|
|
try {
|
|
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
|
|
account = validation.account;
|
|
|
|
// Validate quota amount
|
|
if (request.quotaMb <= 0 || request.quotaMb > 100000) {
|
|
throw new BadRequestException("Quota must be between 1MB and 100GB");
|
|
}
|
|
|
|
// Use amount from request (calculated by frontend)
|
|
const quotaGb = request.quotaMb / 1000;
|
|
const units = Math.ceil(quotaGb);
|
|
const expectedCost = units * 500;
|
|
|
|
costJpy = request.amount ?? expectedCost;
|
|
currency = request.currency ?? "JPY";
|
|
|
|
if (request.amount != null && request.amount !== expectedCost) {
|
|
throw new BadRequestException(
|
|
`Amount mismatch: expected ¥${expectedCost} for ${units}GB, got ¥${request.amount}`
|
|
);
|
|
}
|
|
|
|
// Validate quota against Freebit API limits (100MB - 51200MB)
|
|
if (request.quotaMb < 100 || request.quotaMb > 51200) {
|
|
throw new BadRequestException(
|
|
"Quota must be between 100MB and 51200MB (50GB) for Freebit API compatibility"
|
|
);
|
|
}
|
|
|
|
// Get client mapping for WHMCS
|
|
const mapping = await this.mappingsService.findByUserId(userId);
|
|
if (!mapping?.whmcsClientId) {
|
|
throw new BadRequestException("WHMCS client mapping not found");
|
|
}
|
|
|
|
const whmcsClientId = mapping.whmcsClientId;
|
|
|
|
this.logger.log(`Starting SIM top-up process for subscription ${subscriptionId}`, {
|
|
userId,
|
|
subscriptionId,
|
|
account,
|
|
quotaMb: request.quotaMb,
|
|
quotaGb: quotaGb.toFixed(2),
|
|
costJpy,
|
|
currency,
|
|
});
|
|
|
|
// Step 1: Create WHMCS invoice
|
|
const invoice = await this.whmcsService.createInvoice({
|
|
clientId: whmcsClientId,
|
|
description: `SIM Data Top-up: ${units}GB for ${account}`,
|
|
amount: costJpy,
|
|
currency,
|
|
dueDate: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7 days from now
|
|
notes: `Subscription ID: ${subscriptionId}, Phone: ${account}`,
|
|
});
|
|
|
|
this.logger.log(`Created WHMCS invoice ${invoice.id} for SIM top-up`, {
|
|
invoiceId: invoice.id,
|
|
invoiceNumber: invoice.number,
|
|
amount: costJpy,
|
|
currency,
|
|
subscriptionId,
|
|
});
|
|
|
|
// Step 2: Capture payment
|
|
this.logger.log(`Attempting payment capture`, {
|
|
invoiceId: invoice.id,
|
|
amount: costJpy,
|
|
currency,
|
|
});
|
|
|
|
const paymentResult = await this.whmcsService.capturePayment({
|
|
invoiceId: invoice.id,
|
|
amount: costJpy,
|
|
currency,
|
|
});
|
|
|
|
if (!paymentResult.success) {
|
|
this.logger.error(`Payment capture failed for invoice ${invoice.id}`, {
|
|
invoiceId: invoice.id,
|
|
error: paymentResult.error,
|
|
subscriptionId,
|
|
});
|
|
|
|
// Cancel the invoice since payment failed
|
|
await this.handlePaymentFailure(invoice.id, paymentResult.error || "Unknown payment error");
|
|
|
|
throw new BadRequestException(`SIM top-up failed: ${paymentResult.error}`);
|
|
}
|
|
|
|
this.logger.log(`Payment captured successfully for invoice ${invoice.id}`, {
|
|
invoiceId: invoice.id,
|
|
transactionId: paymentResult.transactionId,
|
|
amount: costJpy,
|
|
currency,
|
|
subscriptionId,
|
|
});
|
|
|
|
try {
|
|
// Step 3: Only if payment successful, add data via Freebit
|
|
await this.freebitService.topUpSim(account, request.quotaMb, {});
|
|
|
|
this.logger.log(`Successfully topped up SIM for subscription ${subscriptionId}`, {
|
|
userId,
|
|
subscriptionId,
|
|
account,
|
|
quotaMb: request.quotaMb,
|
|
costJpy,
|
|
currency,
|
|
invoiceId: invoice.id,
|
|
transactionId: paymentResult.transactionId,
|
|
});
|
|
|
|
await this.simNotification.notifySimAction("Top Up Data", "SUCCESS", {
|
|
userId,
|
|
subscriptionId,
|
|
account,
|
|
quotaMb: request.quotaMb,
|
|
costJpy,
|
|
currency,
|
|
invoiceId: invoice.id,
|
|
transactionId: paymentResult.transactionId,
|
|
});
|
|
} catch (freebitError) {
|
|
// If Freebit fails after payment, handle carefully
|
|
await this.handleFreebitFailureAfterPayment(
|
|
freebitError,
|
|
invoice,
|
|
paymentResult.transactionId || "unknown",
|
|
userId,
|
|
subscriptionId,
|
|
account,
|
|
request.quotaMb
|
|
);
|
|
}
|
|
} catch (error) {
|
|
const sanitizedError = getErrorMessage(error);
|
|
this.logger.error(`Failed to top up SIM for subscription ${subscriptionId}`, {
|
|
error: sanitizedError,
|
|
userId,
|
|
subscriptionId,
|
|
quotaMb: request.quotaMb,
|
|
costJpy,
|
|
currency,
|
|
});
|
|
await this.simNotification.notifySimAction("Top Up Data", "ERROR", {
|
|
userId,
|
|
subscriptionId,
|
|
account: account ?? "",
|
|
quotaMb: request.quotaMb,
|
|
costJpy,
|
|
currency,
|
|
error: sanitizedError,
|
|
});
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Handle payment failure by canceling the invoice
|
|
*/
|
|
private async handlePaymentFailure(invoiceId: number, error: string): Promise<void> {
|
|
try {
|
|
await this.whmcsService.updateInvoice({
|
|
invoiceId,
|
|
status: "Cancelled",
|
|
notes: `Payment capture failed: ${error}. Invoice cancelled automatically.`,
|
|
});
|
|
|
|
this.logger.log(`Cancelled invoice ${invoiceId} due to payment failure`, {
|
|
invoiceId,
|
|
reason: "Payment capture failed",
|
|
});
|
|
} catch (cancelError) {
|
|
this.logger.error(`Failed to cancel invoice ${invoiceId} after payment failure`, {
|
|
invoiceId,
|
|
cancelError: getErrorMessage(cancelError),
|
|
originalError: error,
|
|
});
|
|
}
|
|
}
|
|
|
|
/**
|
|
* 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: getErrorMessage(freebitError),
|
|
userId,
|
|
subscriptionId,
|
|
account,
|
|
quotaMb,
|
|
invoiceId: invoice.id,
|
|
transactionId,
|
|
paymentCaptured: true,
|
|
}
|
|
);
|
|
|
|
// Add a note to the invoice about the Freebit failure
|
|
try {
|
|
await this.whmcsService.updateInvoice({
|
|
invoiceId: invoice.id,
|
|
notes: `Payment successful but SIM top-up failed: ${getErrorMessage(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: getErrorMessage(updateError),
|
|
originalError: getErrorMessage(freebitError),
|
|
});
|
|
}
|
|
|
|
// TODO: Implement refund logic here
|
|
// await this.whmcsService.addCredit({
|
|
// clientId: whmcsClientId,
|
|
// description: `Refund for failed SIM top-up (Invoice: ${invoice.number})`,
|
|
// amount: costJpy,
|
|
// type: 'refund'
|
|
// });
|
|
|
|
const errMsg = `Payment was processed but SIM data top-up failed. Please contact support with invoice ${invoice.number} for assistance.`;
|
|
await this.simNotification.notifySimAction("Top Up Data", "ERROR", {
|
|
userId,
|
|
subscriptionId,
|
|
account,
|
|
quotaMb,
|
|
invoiceId: invoice.id,
|
|
transactionId,
|
|
error: getErrorMessage(freebitError),
|
|
});
|
|
throw new Error(errMsg);
|
|
}
|
|
}
|