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