tema 675f7d5cfd Remove cached profile fields migration and update CSRF middleware for new public auth endpoints
- 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.
2025-11-21 17:12:34 +09:00

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