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:
parent
ed5c2ead63
commit
cdcdb4c172
10
apps/bff/src/core/utils/validation.util.ts
Normal file
10
apps/bff/src/core/utils/validation.util.ts
Normal 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;
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -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({
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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`);
|
||||
},
|
||||
};
|
||||
|
||||
@ -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;
|
||||
}
|
||||
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user