Enhance User ID Handling and Validation Across Services

- Added optional userId parameter to payment capture methods in WhmcsService and WhmcsInvoiceService to improve tracking and management of user-related transactions.
- Updated invoice retrieval and user profile services to utilize parseUuidOrThrow for user ID validation, ensuring consistent error messaging for invalid formats.
- Refactored SIM billing and activation services to include userId in one-time charge creation, enhancing billing traceability.
- Adjusted validation logic in various services to improve clarity and maintainability, ensuring robust handling of user IDs throughout the application.
This commit is contained in:
barsa 2025-12-29 14:21:55 +09:00
parent ed5c2ead63
commit cdcdb4c172
13 changed files with 51 additions and 32 deletions

View File

@ -0,0 +1,10 @@
import { BadRequestException } from "@nestjs/common";
import { uuidSchema } from "@customer-portal/domain/common";
export function parseUuidOrThrow(id: string, message = "Invalid ID format"): string {
const parsed = uuidSchema.safeParse(id);
if (!parsed.success) {
throw new BadRequestException(message);
}
return parsed.data;
}

View File

@ -394,6 +394,7 @@ export class WhmcsInvoiceService {
invoiceId: number;
amount: number;
currency?: string;
userId?: string;
}): Promise<{ success: boolean; transactionId?: string; error?: string }> {
try {
const whmcsParams: WhmcsCapturePaymentParams = {
@ -411,7 +412,9 @@ export class WhmcsInvoiceService {
});
// Invalidate invoice cache since status changed
await this.cacheService.invalidateInvoice(`invoice-${params.invoiceId}`, params.invoiceId);
if (params.userId) {
await this.cacheService.invalidateInvoice(params.userId, params.invoiceId);
}
return {
success: true,

View File

@ -333,6 +333,7 @@ export class WhmcsService {
invoiceId: number;
amount: number;
currency?: string;
userId?: string;
}): Promise<{ success: boolean; transactionId?: string; error?: string }> {
return this.invoiceService.capturePayment(params);
}

View File

@ -6,10 +6,10 @@ import type {
InvoiceListQuery,
InvoiceStatus,
} from "@customer-portal/domain/billing";
import { validateUuidV4OrThrow } from "@customer-portal/domain/common";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { withErrorHandling } from "@bff/core/utils/error-handler.util.js";
import { parseUuidOrThrow } from "@bff/core/utils/validation.util.js";
/**
* Service responsible for retrieving invoices from WHMCS
@ -32,7 +32,7 @@ export class InvoiceRetrievalService {
const { page = 1, limit = 10, status } = options;
// Validate userId first
validateUuidV4OrThrow(userId);
parseUuidOrThrow(userId, "Invalid user ID format");
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
return withErrorHandling(
@ -61,7 +61,7 @@ export class InvoiceRetrievalService {
* Get individual invoice by ID
*/
async getInvoiceById(userId: string, invoiceId: number): Promise<Invoice> {
validateUuidV4OrThrow(userId);
parseUuidOrThrow(userId, "Invalid user ID format");
const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId);
return withErrorHandling(

View File

@ -10,6 +10,7 @@ export interface SimChargeInvoiceResult {
interface OneTimeChargeParams {
clientId: number;
userId?: string;
description: string;
amountJpy: number;
currency?: string;
@ -30,6 +31,7 @@ export class SimBillingService {
async createOneTimeCharge(params: OneTimeChargeParams): Promise<SimChargeInvoiceResult> {
const {
clientId,
userId,
description,
amountJpy,
currency = "JPY",
@ -53,6 +55,7 @@ export class SimBillingService {
invoiceId: invoice.id,
amount: amountJpy,
currency,
userId,
});
if (!paymentResult.success) {

View File

@ -9,6 +9,7 @@ import type { SimTopUpRequest } from "@customer-portal/domain/sim";
import { SimBillingService } from "./sim-billing.service.js";
import { SimActionRunnerService } from "./sim-action-runner.service.js";
import { SimApiNotificationService } from "./sim-api-notification.service.js";
import { SimTopUpPricingService } from "./sim-topup-pricing.service.js";
@Injectable()
export class SimTopUpService {
@ -20,6 +21,7 @@ export class SimTopUpService {
private readonly simActionRunner: SimActionRunnerService,
private readonly apiNotification: SimApiNotificationService,
private readonly configService: ConfigService,
private readonly simTopUpPricing: SimTopUpPricingService,
@Inject(Logger) private readonly logger: Logger
) {}
@ -63,17 +65,20 @@ export class SimTopUpService {
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
latestAccount = validation.account;
if (request.quotaMb <= 0 || request.quotaMb > 100000) {
throw new BadRequestException("Quota must be between 1MB and 100GB");
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 * 500;
const costJpy = units * pricing.pricePerGbJpy;
if (request.quotaMb < 100 || request.quotaMb > 51200) {
if (request.quotaMb < minQuotaMb || request.quotaMb > maxQuotaMb) {
throw new BadRequestException(
"Quota must be between 100MB and 51200MB (50GB) for Freebit API compatibility"
`Quota must be between ${minQuotaMb}MB and ${maxQuotaMb}MB for Freebit API compatibility`
);
}
@ -90,6 +95,7 @@ export class SimTopUpService {
const billing = await this.simBilling.createOneTimeCharge({
clientId: whmcsClientId,
userId,
description: `SIM Data Top-up: ${units}GB for ${latestAccount}`,
amountJpy: costJpy,
currency: "JPY",

View File

@ -109,6 +109,7 @@ export class SimOrderActivationService {
try {
billingResult = await this.simBilling.createOneTimeCharge({
clientId: whmcsClientId,
userId,
description: `SIM Activation Fee (${req.planSku}) for ${req.msisdn}`,
amountJpy: req.oneTimeAmountJpy,
currency: "JPY",

View File

@ -1,8 +1,9 @@
import { Injectable, BadRequestException } from "@nestjs/common";
import type { User as PrismaUser } from "@prisma/client";
import { PrismaService } from "@bff/infra/database/prisma.service.js";
import { normalizeAndValidateEmail, validateUuidV4OrThrow } from "@customer-portal/domain/common";
import { normalizeAndValidateEmail } from "@customer-portal/domain/common";
import { getErrorMessage } from "@bff/core/utils/error.util.js";
import { parseUuidOrThrow } from "@bff/core/utils/validation.util.js";
type AuthUpdatableFields = Pick<
PrismaUser,
@ -23,7 +24,7 @@ export class UserAuthRepository {
}
async findById(id: string): Promise<PrismaUser | null> {
const validId = validateUuidV4OrThrow(id);
const validId = parseUuidOrThrow(id, "Invalid user ID format");
try {
return await this.prisma.user.findUnique({ where: { id: validId } });
} catch (error) {
@ -51,7 +52,7 @@ export class UserAuthRepository {
}
async updateAuthState(id: string, data: Partial<AuthUpdatableFields>): Promise<void> {
const validId = validateUuidV4OrThrow(id);
const validId = parseUuidOrThrow(id, "Invalid user ID format");
try {
await this.prisma.user.update({
@ -64,7 +65,7 @@ export class UserAuthRepository {
}
async updateEmail(id: string, email: string): Promise<void> {
const validId = validateUuidV4OrThrow(id);
const validId = parseUuidOrThrow(id, "Invalid user ID format");
const normalized = normalizeAndValidateEmail(email);
try {
await this.prisma.user.update({

View File

@ -30,8 +30,8 @@ import { dashboardSummarySchema } from "@customer-portal/domain/dashboard";
import { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service.js";
import { validateUuidV4OrThrow } from "@customer-portal/domain/common";
import { withErrorHandling } from "@bff/core/utils/error-handler.util.js";
import { parseUuidOrThrow } from "@bff/core/utils/validation.util.js";
import { UserAuthRepository } from "./user-auth.repository.js";
@Injectable()
@ -46,7 +46,7 @@ export class UserProfileService {
) {}
async findById(userId: string): Promise<User | null> {
const validId = validateUuidV4OrThrow(userId);
const validId = parseUuidOrThrow(userId, "Invalid user ID format");
const user = await this.userAuthRepository.findById(validId);
if (!user) {
return null;
@ -55,7 +55,7 @@ export class UserProfileService {
}
async getProfile(userId: string): Promise<User> {
const validId = validateUuidV4OrThrow(userId);
const validId = parseUuidOrThrow(userId, "Invalid user ID format");
const user = await this.userAuthRepository.findById(validId);
if (!user) {
throw new NotFoundException("User not found");
@ -69,7 +69,7 @@ export class UserProfileService {
}
async updateAddress(userId: string, addressUpdate: Partial<Address>): Promise<Address> {
const validId = validateUuidV4OrThrow(userId);
const validId = parseUuidOrThrow(userId, "Invalid user ID format");
const parsed = addressSchema.partial().parse(addressUpdate ?? {});
const hasUpdates = Object.values(parsed).some(value => value !== undefined);
@ -106,7 +106,7 @@ export class UserProfileService {
}
async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise<User> {
const validId = validateUuidV4OrThrow(userId);
const validId = parseUuidOrThrow(userId, "Invalid user ID format");
const parsed = updateCustomerProfileRequestSchema.parse(update);
return withErrorHandling(

View File

@ -5,7 +5,7 @@
*/
import { apiClient, getDataOrThrow } from "@/lib/api";
import type { ApiSuccessAckResponse } from "@customer-portal/domain/common";
import type { ActionAckResponse } from "@customer-portal/domain/common";
import type { NotificationListResponse } from "@customer-portal/domain/notifications";
const BASE_PATH = "/api/notifications";
@ -43,20 +43,20 @@ export const notificationService = {
* Mark a notification as read
*/
async markAsRead(notificationId: string): Promise<void> {
await apiClient.POST<ApiSuccessAckResponse>(`${BASE_PATH}/${notificationId}/read`);
await apiClient.POST<ActionAckResponse>(`${BASE_PATH}/${notificationId}/read`);
},
/**
* Mark all notifications as read
*/
async markAllAsRead(): Promise<void> {
await apiClient.POST<ApiSuccessAckResponse>(`${BASE_PATH}/read-all`);
await apiClient.POST<ActionAckResponse>(`${BASE_PATH}/read-all`);
},
/**
* Dismiss a notification
*/
async dismiss(notificationId: string): Promise<void> {
await apiClient.POST<ApiSuccessAckResponse>(`${BASE_PATH}/${notificationId}/dismiss`);
await apiClient.POST<ActionAckResponse>(`${BASE_PATH}/${notificationId}/dismiss`);
},
};

View File

@ -34,7 +34,7 @@ export function TopUpModal({ subscriptionId, onClose, onSuccess, onError }: TopU
e.preventDefault();
if (!isValidAmount()) {
onError("Please enter a whole number between 1 GB and 100 GB");
onError("Please enter a whole number between 1 GB and 50 GB");
return;
}

View File

@ -53,13 +53,7 @@ export const VALID_INVOICE_STATUSES = [
/**
* Invoice status for list filtering (subset of all statuses)
*/
export const VALID_INVOICE_LIST_STATUSES = [
"Paid",
"Unpaid",
"Cancelled",
"Overdue",
"Collections",
] as const;
export const VALID_INVOICE_LIST_STATUSES = VALID_INVOICE_STATUSES;
// ============================================================================
// Validation Helpers

View File

@ -18,10 +18,10 @@ export function isValidPositiveId(id: number): boolean {
}
/**
* Validate Salesforce ID (18 characters, alphanumeric)
* Validate Salesforce ID (15 or 18 characters, alphanumeric)
*/
export function isValidSalesforceId(id: string): boolean {
return /^[A-Za-z0-9]{18}$/.test(id);
return /^[A-Za-z0-9]{15,18}$/.test(id);
}
// ============================================================================