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; invoiceId: number;
amount: number; amount: number;
currency?: string; currency?: string;
userId?: string;
}): Promise<{ success: boolean; transactionId?: string; error?: string }> { }): Promise<{ success: boolean; transactionId?: string; error?: string }> {
try { try {
const whmcsParams: WhmcsCapturePaymentParams = { const whmcsParams: WhmcsCapturePaymentParams = {
@ -411,7 +412,9 @@ export class WhmcsInvoiceService {
}); });
// Invalidate invoice cache since status changed // 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 { return {
success: true, success: true,

View File

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

View File

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

View File

@ -10,6 +10,7 @@ export interface SimChargeInvoiceResult {
interface OneTimeChargeParams { interface OneTimeChargeParams {
clientId: number; clientId: number;
userId?: string;
description: string; description: string;
amountJpy: number; amountJpy: number;
currency?: string; currency?: string;
@ -30,6 +31,7 @@ export class SimBillingService {
async createOneTimeCharge(params: OneTimeChargeParams): Promise<SimChargeInvoiceResult> { async createOneTimeCharge(params: OneTimeChargeParams): Promise<SimChargeInvoiceResult> {
const { const {
clientId, clientId,
userId,
description, description,
amountJpy, amountJpy,
currency = "JPY", currency = "JPY",
@ -53,6 +55,7 @@ export class SimBillingService {
invoiceId: invoice.id, invoiceId: invoice.id,
amount: amountJpy, amount: amountJpy,
currency, currency,
userId,
}); });
if (!paymentResult.success) { 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 { SimBillingService } from "./sim-billing.service.js";
import { SimActionRunnerService } from "./sim-action-runner.service.js"; import { SimActionRunnerService } from "./sim-action-runner.service.js";
import { SimApiNotificationService } from "./sim-api-notification.service.js"; import { SimApiNotificationService } from "./sim-api-notification.service.js";
import { SimTopUpPricingService } from "./sim-topup-pricing.service.js";
@Injectable() @Injectable()
export class SimTopUpService { export class SimTopUpService {
@ -20,6 +21,7 @@ export class SimTopUpService {
private readonly simActionRunner: SimActionRunnerService, private readonly simActionRunner: SimActionRunnerService,
private readonly apiNotification: SimApiNotificationService, private readonly apiNotification: SimApiNotificationService,
private readonly configService: ConfigService, private readonly configService: ConfigService,
private readonly simTopUpPricing: SimTopUpPricingService,
@Inject(Logger) private readonly logger: Logger @Inject(Logger) private readonly logger: Logger
) {} ) {}
@ -63,17 +65,20 @@ export class SimTopUpService {
const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId); const validation = await this.simValidation.validateSimSubscription(userId, subscriptionId);
latestAccount = validation.account; latestAccount = validation.account;
if (request.quotaMb <= 0 || request.quotaMb > 100000) { if (request.quotaMb <= 0) {
throw new BadRequestException("Quota must be between 1MB and 100GB"); 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 quotaGb = request.quotaMb / 1000;
const units = Math.ceil(quotaGb); 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( 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({ const billing = await this.simBilling.createOneTimeCharge({
clientId: whmcsClientId, clientId: whmcsClientId,
userId,
description: `SIM Data Top-up: ${units}GB for ${latestAccount}`, description: `SIM Data Top-up: ${units}GB for ${latestAccount}`,
amountJpy: costJpy, amountJpy: costJpy,
currency: "JPY", currency: "JPY",

View File

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

View File

@ -1,8 +1,9 @@
import { Injectable, BadRequestException } from "@nestjs/common"; import { Injectable, BadRequestException } from "@nestjs/common";
import type { User as PrismaUser } from "@prisma/client"; import type { User as PrismaUser } from "@prisma/client";
import { PrismaService } from "@bff/infra/database/prisma.service.js"; 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 { getErrorMessage } from "@bff/core/utils/error.util.js";
import { parseUuidOrThrow } from "@bff/core/utils/validation.util.js";
type AuthUpdatableFields = Pick< type AuthUpdatableFields = Pick<
PrismaUser, PrismaUser,
@ -23,7 +24,7 @@ export class UserAuthRepository {
} }
async findById(id: string): Promise<PrismaUser | null> { async findById(id: string): Promise<PrismaUser | null> {
const validId = validateUuidV4OrThrow(id); const validId = parseUuidOrThrow(id, "Invalid user ID format");
try { try {
return await this.prisma.user.findUnique({ where: { id: validId } }); return await this.prisma.user.findUnique({ where: { id: validId } });
} catch (error) { } catch (error) {
@ -51,7 +52,7 @@ export class UserAuthRepository {
} }
async updateAuthState(id: string, data: Partial<AuthUpdatableFields>): Promise<void> { async updateAuthState(id: string, data: Partial<AuthUpdatableFields>): Promise<void> {
const validId = validateUuidV4OrThrow(id); const validId = parseUuidOrThrow(id, "Invalid user ID format");
try { try {
await this.prisma.user.update({ await this.prisma.user.update({
@ -64,7 +65,7 @@ export class UserAuthRepository {
} }
async updateEmail(id: string, email: string): Promise<void> { async updateEmail(id: string, email: string): Promise<void> {
const validId = validateUuidV4OrThrow(id); const validId = parseUuidOrThrow(id, "Invalid user ID format");
const normalized = normalizeAndValidateEmail(email); const normalized = normalizeAndValidateEmail(email);
try { try {
await this.prisma.user.update({ 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 { MappingsService } from "@bff/modules/id-mappings/mappings.service.js";
import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js"; import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service.js";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.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 { 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"; import { UserAuthRepository } from "./user-auth.repository.js";
@Injectable() @Injectable()
@ -46,7 +46,7 @@ export class UserProfileService {
) {} ) {}
async findById(userId: string): Promise<User | null> { 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); const user = await this.userAuthRepository.findById(validId);
if (!user) { if (!user) {
return null; return null;
@ -55,7 +55,7 @@ export class UserProfileService {
} }
async getProfile(userId: string): Promise<User> { 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); const user = await this.userAuthRepository.findById(validId);
if (!user) { if (!user) {
throw new NotFoundException("User not found"); throw new NotFoundException("User not found");
@ -69,7 +69,7 @@ export class UserProfileService {
} }
async updateAddress(userId: string, addressUpdate: Partial<Address>): Promise<Address> { 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 parsed = addressSchema.partial().parse(addressUpdate ?? {});
const hasUpdates = Object.values(parsed).some(value => value !== undefined); const hasUpdates = Object.values(parsed).some(value => value !== undefined);
@ -106,7 +106,7 @@ export class UserProfileService {
} }
async updateProfile(userId: string, update: UpdateCustomerProfileRequest): Promise<User> { 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); const parsed = updateCustomerProfileRequestSchema.parse(update);
return withErrorHandling( return withErrorHandling(

View File

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

View File

@ -53,13 +53,7 @@ export const VALID_INVOICE_STATUSES = [
/** /**
* Invoice status for list filtering (subset of all statuses) * Invoice status for list filtering (subset of all statuses)
*/ */
export const VALID_INVOICE_LIST_STATUSES = [ export const VALID_INVOICE_LIST_STATUSES = VALID_INVOICE_STATUSES;
"Paid",
"Unpaid",
"Cancelled",
"Overdue",
"Collections",
] as const;
// ============================================================================ // ============================================================================
// Validation Helpers // 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 { export function isValidSalesforceId(id: string): boolean {
return /^[A-Za-z0-9]{18}$/.test(id); return /^[A-Za-z0-9]{15,18}$/.test(id);
} }
// ============================================================================ // ============================================================================