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;
|
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,
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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) {
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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",
|
||||||
|
|||||||
@ -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({
|
||||||
|
|||||||
@ -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(
|
||||||
|
|||||||
@ -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`);
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ============================================================================
|
// ============================================================================
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user