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