From cdcdb4c172b6085a8154b8b99780736c4daaeab9 Mon Sep 17 00:00:00 2001 From: barsa Date: Mon, 29 Dec 2025 14:21:55 +0900 Subject: [PATCH] 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. --- apps/bff/src/core/utils/validation.util.ts | 10 ++++++++++ .../whmcs/services/whmcs-invoice.service.ts | 5 ++++- apps/bff/src/integrations/whmcs/whmcs.service.ts | 1 + .../services/invoice-retrieval.service.ts | 6 +++--- .../services/sim-billing.service.ts | 3 +++ .../sim-management/services/sim-topup.service.ts | 16 +++++++++++----- .../sim-order-activation.service.ts | 1 + .../modules/users/infra/user-auth.repository.ts | 9 +++++---- .../modules/users/infra/user-profile.service.ts | 10 +++++----- .../services/notification.service.ts | 8 ++++---- .../src/features/sim/components/TopUpModal.tsx | 2 +- packages/domain/billing/constants.ts | 8 +------- packages/domain/toolkit/validation/helpers.ts | 4 ++-- 13 files changed, 51 insertions(+), 32 deletions(-) create mode 100644 apps/bff/src/core/utils/validation.util.ts diff --git a/apps/bff/src/core/utils/validation.util.ts b/apps/bff/src/core/utils/validation.util.ts new file mode 100644 index 00000000..c008390f --- /dev/null +++ b/apps/bff/src/core/utils/validation.util.ts @@ -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; +} diff --git a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts index 98583458..02ba4999 100644 --- a/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts +++ b/apps/bff/src/integrations/whmcs/services/whmcs-invoice.service.ts @@ -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, diff --git a/apps/bff/src/integrations/whmcs/whmcs.service.ts b/apps/bff/src/integrations/whmcs/whmcs.service.ts index 63cd078a..3bbcb991 100644 --- a/apps/bff/src/integrations/whmcs/whmcs.service.ts +++ b/apps/bff/src/integrations/whmcs/whmcs.service.ts @@ -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); } diff --git a/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts b/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts index 9dae1210..7adb986b 100644 --- a/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts +++ b/apps/bff/src/modules/billing/services/invoice-retrieval.service.ts @@ -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 { - validateUuidV4OrThrow(userId); + parseUuidOrThrow(userId, "Invalid user ID format"); const whmcsClientId = await this.mappingsService.getWhmcsClientIdOrThrow(userId); return withErrorHandling( diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts index 13ae4c62..8e7191b2 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-billing.service.ts @@ -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 { const { clientId, + userId, description, amountJpy, currency = "JPY", @@ -53,6 +55,7 @@ export class SimBillingService { invoiceId: invoice.id, amount: amountJpy, currency, + userId, }); if (!paymentResult.success) { diff --git a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts b/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts index 8d4a71ba..e7427d35 100644 --- a/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts @@ -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", diff --git a/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts b/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts index dc85bba8..9af987b4 100644 --- a/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts +++ b/apps/bff/src/modules/subscriptions/sim-order-activation.service.ts @@ -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", diff --git a/apps/bff/src/modules/users/infra/user-auth.repository.ts b/apps/bff/src/modules/users/infra/user-auth.repository.ts index 58e398d3..71371338 100644 --- a/apps/bff/src/modules/users/infra/user-auth.repository.ts +++ b/apps/bff/src/modules/users/infra/user-auth.repository.ts @@ -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 { - 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): Promise { - 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 { - const validId = validateUuidV4OrThrow(id); + const validId = parseUuidOrThrow(id, "Invalid user ID format"); const normalized = normalizeAndValidateEmail(email); try { await this.prisma.user.update({ diff --git a/apps/bff/src/modules/users/infra/user-profile.service.ts b/apps/bff/src/modules/users/infra/user-profile.service.ts index 18688ad8..5e34897b 100644 --- a/apps/bff/src/modules/users/infra/user-profile.service.ts +++ b/apps/bff/src/modules/users/infra/user-profile.service.ts @@ -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 { - 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 { - 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
): Promise
{ - 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 { - const validId = validateUuidV4OrThrow(userId); + const validId = parseUuidOrThrow(userId, "Invalid user ID format"); const parsed = updateCustomerProfileRequestSchema.parse(update); return withErrorHandling( diff --git a/apps/portal/src/features/notifications/services/notification.service.ts b/apps/portal/src/features/notifications/services/notification.service.ts index a36e935c..96d022d2 100644 --- a/apps/portal/src/features/notifications/services/notification.service.ts +++ b/apps/portal/src/features/notifications/services/notification.service.ts @@ -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 { - await apiClient.POST(`${BASE_PATH}/${notificationId}/read`); + await apiClient.POST(`${BASE_PATH}/${notificationId}/read`); }, /** * Mark all notifications as read */ async markAllAsRead(): Promise { - await apiClient.POST(`${BASE_PATH}/read-all`); + await apiClient.POST(`${BASE_PATH}/read-all`); }, /** * Dismiss a notification */ async dismiss(notificationId: string): Promise { - await apiClient.POST(`${BASE_PATH}/${notificationId}/dismiss`); + await apiClient.POST(`${BASE_PATH}/${notificationId}/dismiss`); }, }; diff --git a/apps/portal/src/features/sim/components/TopUpModal.tsx b/apps/portal/src/features/sim/components/TopUpModal.tsx index 658e0b54..6752744e 100644 --- a/apps/portal/src/features/sim/components/TopUpModal.tsx +++ b/apps/portal/src/features/sim/components/TopUpModal.tsx @@ -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; } diff --git a/packages/domain/billing/constants.ts b/packages/domain/billing/constants.ts index ce52c55b..03b683a3 100644 --- a/packages/domain/billing/constants.ts +++ b/packages/domain/billing/constants.ts @@ -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 diff --git a/packages/domain/toolkit/validation/helpers.ts b/packages/domain/toolkit/validation/helpers.ts index 34bede74..3f9a490c 100644 --- a/packages/domain/toolkit/validation/helpers.ts +++ b/packages/domain/toolkit/validation/helpers.ts @@ -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); } // ============================================================================