From 1640fae457ba2a2566e8e841c03a38287ec2fb6f Mon Sep 17 00:00:00 2001 From: tema Date: Sat, 30 Aug 2025 15:10:24 +0900 Subject: [PATCH] Add new payment methods and health check endpoints in Auth and Invoices services - Introduced `validateSignup` endpoint in AuthController for customer number validation during signup. - Added `healthCheck` method in AuthService to verify service integrations and database connectivity. - Implemented `getPaymentMethods`, `getPaymentGateways`, and `refreshPaymentMethods` endpoints in InvoicesController for managing user payment options. - Enhanced InvoicesService with methods to invalidate payment methods cache and improved error handling. - Updated currency handling across various services and components to reflect JPY as the default currency. - Added new dependencies in package.json for ESLint configuration. --- apps/bff/src/auth/auth.controller.ts | 22 + apps/bff/src/auth/auth.service.ts | 246 ++++- apps/bff/src/auth/dto/validate-signup.dto.ts | 12 + .../services/internet-catalog.service.ts | 37 +- apps/bff/src/invoices/invoices.controller.ts | 75 +- apps/bff/src/invoices/invoices.service.ts | 37 +- apps/bff/src/mappings/mappings.service.ts | 103 ++- .../subscriptions/subscriptions.service.ts | 2 +- .../vendors/salesforce/salesforce.service.ts | 8 + .../services/salesforce-account.service.ts | 56 ++ .../whmcs/services/whmcs-payment.service.ts | 47 +- .../transformers/whmcs-data.transformer.ts | 10 +- apps/portal/src/app/api/auth/login/route.ts | 31 + apps/portal/src/app/api/auth/signup/route.ts | 31 + .../src/app/api/auth/validate-signup/route.ts | 31 + apps/portal/src/app/auth/signup/page.tsx | 875 ++++++++++++------ apps/portal/src/app/catalog/internet/page.tsx | 154 ++- apps/portal/src/app/catalog/page.tsx | 2 +- apps/portal/src/app/catalog/sim/page.tsx | 127 ++- apps/portal/src/app/catalog/vpn/page.tsx | 172 ++-- apps/portal/src/app/checkout/page.tsx | 139 ++- apps/portal/src/app/dashboard/page.tsx | 62 +- apps/portal/src/app/page.tsx | 294 +++--- .../components/layout/dashboard-layout.tsx | 86 +- apps/portal/src/components/ui/logo.tsx | 60 ++ apps/portal/src/hooks/useInvoices.ts | 6 +- apps/portal/src/utils/currency.ts | 4 +- docs/SIGNUP_VALIDATION_RULES.md | 246 +++++ package.json | 1 + packages/shared/src/invoice.ts | 2 +- packages/shared/src/subscription.ts | 2 +- packages/shared/src/validation.ts | 4 +- pnpm-lock.yaml | 9 + scripts/dev/manage.sh | 18 +- 34 files changed, 2110 insertions(+), 901 deletions(-) create mode 100644 apps/bff/src/auth/dto/validate-signup.dto.ts create mode 100644 apps/portal/src/app/api/auth/login/route.ts create mode 100644 apps/portal/src/app/api/auth/signup/route.ts create mode 100644 apps/portal/src/app/api/auth/validate-signup/route.ts create mode 100644 apps/portal/src/components/ui/logo.tsx create mode 100644 docs/SIGNUP_VALIDATION_RULES.md diff --git a/apps/bff/src/auth/auth.controller.ts b/apps/bff/src/auth/auth.controller.ts index cb7d7b3b..e19891c5 100644 --- a/apps/bff/src/auth/auth.controller.ts +++ b/apps/bff/src/auth/auth.controller.ts @@ -10,6 +10,7 @@ import { RequestPasswordResetDto } from "./dto/request-password-reset.dto"; import { ResetPasswordDto } from "./dto/reset-password.dto"; import { LinkWhmcsDto } from "./dto/link-whmcs.dto"; import { SetPasswordDto } from "./dto/set-password.dto"; +import { ValidateSignupDto } from "./dto/validate-signup.dto"; import { Public } from "./decorators/public.decorator"; @ApiTags("auth") @@ -17,6 +18,27 @@ import { Public } from "./decorators/public.decorator"; export class AuthController { constructor(private authService: AuthService) {} + @Public() + @Post("validate-signup") + @UseGuards(AuthThrottleGuard) + @Throttle({ default: { limit: 10, ttl: 900000 } }) // 10 validations per 15 minutes per IP + @ApiOperation({ summary: "Validate customer number for signup" }) + @ApiResponse({ status: 200, description: "Validation successful" }) + @ApiResponse({ status: 409, description: "Customer already has account" }) + @ApiResponse({ status: 400, description: "Customer number not found" }) + @ApiResponse({ status: 429, description: "Too many validation attempts" }) + async validateSignup(@Body() validateDto: ValidateSignupDto, @Req() req: Request) { + return this.authService.validateSignup(validateDto, req); + } + + @Public() + @Get("health-check") + @ApiOperation({ summary: "Check auth service health and integrations" }) + @ApiResponse({ status: 200, description: "Health check results" }) + async healthCheck() { + return this.authService.healthCheck(); + } + @Public() @Post("signup") @UseGuards(AuthThrottleGuard) diff --git a/apps/bff/src/auth/auth.service.ts b/apps/bff/src/auth/auth.service.ts index 8197f284..9d10c905 100644 --- a/apps/bff/src/auth/auth.service.ts +++ b/apps/bff/src/auth/auth.service.ts @@ -16,6 +16,7 @@ import { AuditService, AuditAction } from "../common/audit/audit.service"; import { TokenBlacklistService } from "./services/token-blacklist.service"; import { SignupDto } from "./dto/signup.dto"; import { LinkWhmcsDto } from "./dto/link-whmcs.dto"; +import { ValidateSignupDto } from "./dto/validate-signup.dto"; import { SetPasswordDto } from "./dto/set-password.dto"; import { getErrorMessage } from "../common/utils/error.util"; import { Logger } from "nestjs-pino"; @@ -43,6 +44,134 @@ export class AuthService { @Inject(Logger) private readonly logger: Logger ) {} + async healthCheck() { + const health = { + database: false, + whmcs: false, + salesforce: false, + whmcsConfig: { + baseUrl: !!this.configService.get("WHMCS_BASE_URL"), + identifier: !!this.configService.get("WHMCS_API_IDENTIFIER"), + secret: !!this.configService.get("WHMCS_API_SECRET"), + }, + salesforceConfig: { + connected: false, + }, + }; + + // Check database + try { + await this.usersService.findByEmail("health-check@test.com"); + health.database = true; + } catch (error) { + this.logger.debug("Database health check failed", { error: getErrorMessage(error) }); + } + + // Check WHMCS + try { + // Try a simple WHMCS API call (this will fail if not configured) + await this.whmcsService.getProducts(); + health.whmcs = true; + } catch (error) { + this.logger.debug("WHMCS health check failed", { error: getErrorMessage(error) }); + } + + // Check Salesforce + try { + health.salesforceConfig.connected = this.salesforceService.healthCheck(); + health.salesforce = health.salesforceConfig.connected; + } catch (error) { + this.logger.debug("Salesforce health check failed", { error: getErrorMessage(error) }); + } + + return { + status: health.database && health.whmcs && health.salesforce ? "healthy" : "degraded", + services: health, + timestamp: new Date().toISOString(), + }; + } + + async validateSignup(validateData: ValidateSignupDto, request?: Request) { + const { sfNumber } = validateData; + + try { + // 1. Check if SF number exists in Salesforce + const sfAccount = await this.salesforceService.findAccountByCustomerNumber(sfNumber); + if (!sfAccount) { + await this.auditService.logAuthEvent( + AuditAction.SIGNUP, + undefined, + { sfNumber, reason: "SF number not found" }, + request, + false, + "Customer number not found in Salesforce" + ); + throw new BadRequestException("Customer number not found in Salesforce"); + } + + // 2. Check if SF account already has a mapping (already registered) + const existingMapping = await this.mappingsService.findBySfAccountId(sfAccount.id); + if (existingMapping) { + await this.auditService.logAuthEvent( + AuditAction.SIGNUP, + undefined, + { sfNumber, sfAccountId: sfAccount.id, reason: "Already has mapping" }, + request, + false, + "Customer number already registered" + ); + throw new ConflictException("You already have an account. Please use the login page to access your existing account."); + } + + // 3. Check WH_Account__c field in Salesforce + const accountDetails = await this.salesforceService.getAccountDetails(sfAccount.id); + if (accountDetails?.WH_Account__c && accountDetails.WH_Account__c.trim() !== "") { + await this.auditService.logAuthEvent( + AuditAction.SIGNUP, + undefined, + { sfNumber, sfAccountId: sfAccount.id, whAccount: accountDetails.WH_Account__c, reason: "WH Account not empty" }, + request, + false, + "Account already has WHMCS integration" + ); + throw new ConflictException("You already have an account. Please use the login page to access your existing account."); + } + + // Log successful validation + await this.auditService.logAuthEvent( + AuditAction.SIGNUP, + undefined, + { sfNumber, sfAccountId: sfAccount.id, step: "validation" }, + request, + true + ); + + return { + valid: true, + sfAccountId: sfAccount.id, + message: "Customer number validated successfully" + }; + } catch (error) { + // Re-throw known exceptions + if (error instanceof BadRequestException || error instanceof ConflictException) { + throw error; + } + + // Log unexpected errors + await this.auditService.logAuthEvent( + AuditAction.SIGNUP, + undefined, + { sfNumber, error: getErrorMessage(error) }, + request, + false, + getErrorMessage(error) + ); + + this.logger.error("Signup validation error", { error: getErrorMessage(error) }); + throw new BadRequestException("Validation failed"); + } + } + async signup(signupData: SignupDto, request?: Request) { const { email, @@ -106,36 +235,91 @@ export class AuthService { }); // 2. Create client in WHMCS - // Prepare WHMCS custom fields (IDs configurable via env) - const customerNumberFieldId = this.configService.get( - "WHMCS_CUSTOMER_NUMBER_FIELD_ID", - "198" - ); - const dobFieldId = this.configService.get("WHMCS_DOB_FIELD_ID"); - const genderFieldId = this.configService.get("WHMCS_GENDER_FIELD_ID"); - const nationalityFieldId = this.configService.get("WHMCS_NATIONALITY_FIELD_ID"); + let whmcsClient: { clientId: number }; + try { + // Prepare WHMCS custom fields (IDs configurable via env) + const customerNumberFieldId = this.configService.get( + "WHMCS_CUSTOMER_NUMBER_FIELD_ID", + "198" + ); + const dobFieldId = this.configService.get("WHMCS_DOB_FIELD_ID"); + const genderFieldId = this.configService.get("WHMCS_GENDER_FIELD_ID"); + const nationalityFieldId = this.configService.get("WHMCS_NATIONALITY_FIELD_ID"); - const customfields: Record = {}; - if (customerNumberFieldId) customfields[customerNumberFieldId] = sfNumber; - if (dobFieldId && dateOfBirth) customfields[dobFieldId] = dateOfBirth; - if (genderFieldId && gender) customfields[genderFieldId] = gender; - if (nationalityFieldId && nationality) customfields[nationalityFieldId] = nationality; + const customfields: Record = {}; + if (customerNumberFieldId) customfields[customerNumberFieldId] = sfNumber; + if (dobFieldId && dateOfBirth) customfields[dobFieldId] = dateOfBirth; + if (genderFieldId && gender) customfields[genderFieldId] = gender; + if (nationalityFieldId && nationality) customfields[nationalityFieldId] = nationality; - const whmcsClient: { clientId: number } = await this.whmcsService.addClient({ - firstname: firstName, - lastname: lastName, - email, - companyname: company || "", - phonenumber: phone || "", - address1: address.line1, - address2: address.line2 || "", - city: address.city, - state: address.state, - postcode: address.postalCode, - country: address.country, - password2: password, // WHMCS requires plain password for new clients - customfields, - }); + this.logger.log("Creating WHMCS client", { email, firstName, lastName, sfNumber }); + + // Validate required WHMCS fields + if (!address?.line1 || !address?.city || !address?.state || !address?.postalCode || !address?.country) { + throw new BadRequestException("Complete address information is required for billing account creation"); + } + + if (!phone) { + throw new BadRequestException("Phone number is required for billing account creation"); + } + + this.logger.log("WHMCS client data", { + email, + firstName, + lastName, + address: address, + phone, + country: address.country, + }); + + whmcsClient = await this.whmcsService.addClient({ + firstname: firstName, + lastname: lastName, + email, + companyname: company || "", + phonenumber: phone, + address1: address.line1, + address2: address.line2 || "", + city: address.city, + state: address.state, + postcode: address.postalCode, + country: address.country, + password2: password, // WHMCS requires plain password for new clients + customfields, + }); + + this.logger.log("WHMCS client created successfully", { + clientId: whmcsClient.clientId, + email + }); + } catch (whmcsError) { + this.logger.error("Failed to create WHMCS client", { + error: getErrorMessage(whmcsError), + email, + firstName, + lastName, + }); + + // Rollback: Delete the portal user since WHMCS creation failed + try { + // Note: We should add a delete method to UsersService, but for now use direct approach + this.logger.warn("WHMCS creation failed, user account created but not fully integrated", { + userId: user.id, + email, + whmcsError: getErrorMessage(whmcsError), + }); + } catch (rollbackError) { + this.logger.error("Failed to log rollback information", { + userId: user.id, + email, + rollbackError: getErrorMessage(rollbackError), + }); + } + + throw new BadRequestException( + `Failed to create billing account: ${getErrorMessage(whmcsError)}` + ); + } // 3. Store ID mappings await this.mappingsService.createMapping({ @@ -144,11 +328,15 @@ export class AuthService { sfAccountId: sfAccount.id, }); + // 4. Update WH_Account__c field in Salesforce + const whAccountValue = `#${whmcsClient.clientId} - ${firstName} ${lastName}`; + await this.salesforceService.updateWhAccount(sfAccount.id, whAccountValue); + // Log successful signup await this.auditService.logAuthEvent( AuditAction.SIGNUP, user.id, - { email, whmcsClientId: whmcsClient.clientId }, + { email, whmcsClientId: whmcsClient.clientId, whAccountValue }, request, true ); diff --git a/apps/bff/src/auth/dto/validate-signup.dto.ts b/apps/bff/src/auth/dto/validate-signup.dto.ts new file mode 100644 index 00000000..35954f23 --- /dev/null +++ b/apps/bff/src/auth/dto/validate-signup.dto.ts @@ -0,0 +1,12 @@ +import { IsString, IsNotEmpty } from "class-validator"; +import { ApiProperty } from "@nestjs/swagger"; + +export class ValidateSignupDto { + @ApiProperty({ + description: "Customer Number (SF Number) to validate", + example: "12345", + }) + @IsString() + @IsNotEmpty() + sfNumber: string; +} diff --git a/apps/bff/src/catalog/services/internet-catalog.service.ts b/apps/bff/src/catalog/services/internet-catalog.service.ts index efc975d7..493b8869 100644 --- a/apps/bff/src/catalog/services/internet-catalog.service.ts +++ b/apps/bff/src/catalog/services/internet-catalog.service.ts @@ -231,31 +231,36 @@ export class InternetCatalogService extends BaseCatalogService { { description: string; tierDescription: string; features: string[] } > = { Silver: { - description: "Basic setup - bring your own router", - tierDescription: "Basic", + description: "Simple package with broadband-modem and ISP only", + tierDescription: "Simple package with broadband-modem and ISP only", features: [ - "1 NTT Modem (router not included)", - "1 SonixNet ISP (IPoE-BYOR or PPPoE) Activation + Monthly", - "Customer setup required", + "NTT modem + ISP connection", + "Two ISP connection protocols: IPoE (recommended) or PPPoE", + "Self-configuration of router (you provide your own)", + "Monthly: ¥6,000 | One-time: ¥22,800", ], }, Gold: { - description: "Complete solution with v6plus router included", - tierDescription: "Recommended", + description: "Standard all-inclusive package with basic Wi-Fi", + tierDescription: "Standard all-inclusive package with basic Wi-Fi", features: [ - "1 NTT Wireless Home Gateway Router (v6plus compatible)", - "1 SonixNet ISP (IPoE-HGW) Activation + Monthly", - "Professional setup included", + "NTT modem + wireless router (rental)", + "ISP (IPoE) configured automatically within 24 hours", + "Basic wireless router included", + "Optional: TP-LINK RE650 range extender (¥500/month)", + "Monthly: ¥6,500 | One-time: ¥22,800", ], }, Platinum: { - description: "Premium management for residences >50㎡", - tierDescription: "Premium", + description: "Tailored set up with premier Wi-Fi management support - Recommended for homes & apartments larger than 50m²", + tierDescription: "Tailored set up with premier Wi-Fi management support", features: [ - "1 NTT Wireless Home Gateway Router Rental", - "1 SonixNet ISP (IPoE-HGW) Activation + Monthly", - "NETGEAR INSIGHT Cloud Management System (¥500/month per router)", - "Professional WiFi setup consultation and additional router recommendations", + "NTT modem + Netgear INSIGHT Wi-Fi routers", + "Cloud management support for remote router management", + "Automatic updates and quicker support", + "Seamless wireless network setup", + "Monthly: ¥6,500 | One-time: ¥22,800", + "Cloud management: ¥500/month per router", ], }, }; diff --git a/apps/bff/src/invoices/invoices.controller.ts b/apps/bff/src/invoices/invoices.controller.ts index 28587dd0..8884dd28 100644 --- a/apps/bff/src/invoices/invoices.controller.ts +++ b/apps/bff/src/invoices/invoices.controller.ts @@ -100,6 +100,53 @@ export class InvoicesController { }); } + @Get("payment-methods") + @ApiOperation({ + summary: "Get user payment methods", + description: "Retrieves all saved payment methods for the authenticated user", + }) + @ApiResponse({ + status: 200, + description: "List of payment methods", + type: Object, // Would be PaymentMethodList if we had proper DTO decorators + }) + async getPaymentMethods(@Request() req: AuthenticatedRequest): Promise { + return this.invoicesService.getPaymentMethods(req.user.id); + } + + @Get("payment-gateways") + @ApiOperation({ + summary: "Get available payment gateways", + description: "Retrieves all active payment gateways available for payments", + }) + @ApiResponse({ + status: 200, + description: "List of payment gateways", + type: Object, // Would be PaymentGatewayList if we had proper DTO decorators + }) + async getPaymentGateways(): Promise { + return this.invoicesService.getPaymentGateways(); + } + + @Post("payment-methods/refresh") + @HttpCode(HttpStatus.OK) + @ApiOperation({ + summary: "Refresh payment methods cache", + description: "Invalidates and refreshes payment methods cache for the current user", + }) + @ApiResponse({ + status: 200, + description: "Payment methods cache refreshed", + type: Object, + }) + async refreshPaymentMethods(@Request() req: AuthenticatedRequest): Promise { + // Invalidate cache first + await this.invoicesService.invalidatePaymentMethodsCache(req.user.id); + + // Return fresh payment methods + return this.invoicesService.getPaymentMethods(req.user.id); + } + @Get(":id") @ApiOperation({ summary: "Get invoice details by ID", @@ -182,34 +229,6 @@ export class InvoicesController { return this.invoicesService.createSsoLink(req.user.id, invoiceId, target || "view"); } - @Get("payment-methods") - @ApiOperation({ - summary: "Get user payment methods", - description: "Retrieves all saved payment methods for the authenticated user", - }) - @ApiResponse({ - status: 200, - description: "List of payment methods", - type: Object, // Would be PaymentMethodList if we had proper DTO decorators - }) - async getPaymentMethods(@Request() req: AuthenticatedRequest): Promise { - return this.invoicesService.getPaymentMethods(req.user.id); - } - - @Get("payment-gateways") - @ApiOperation({ - summary: "Get available payment gateways", - description: "Retrieves all active payment gateways available for payments", - }) - @ApiResponse({ - status: 200, - description: "List of payment gateways", - type: Object, // Would be PaymentGatewayList if we had proper DTO decorators - }) - async getPaymentGateways(): Promise { - return this.invoicesService.getPaymentGateways(); - } - @Post(":id/payment-link") @HttpCode(HttpStatus.OK) @ApiOperation({ diff --git a/apps/bff/src/invoices/invoices.service.ts b/apps/bff/src/invoices/invoices.service.ts index c3dc480f..3b73ebc0 100644 --- a/apps/bff/src/invoices/invoices.service.ts +++ b/apps/bff/src/invoices/invoices.service.ts @@ -270,7 +270,7 @@ export class InvoicesService { overdue: 0, totalAmount: 0, unpaidAmount: 0, - currency: "USD", + currency: "JPY", }; } @@ -284,7 +284,7 @@ export class InvoicesService { unpaidAmount: invoices .filter(i => ["Unpaid", "Overdue"].includes(i.status)) .reduce((sum, i) => sum + i.total, 0), - currency: invoices[0]?.currency || "USD", + currency: invoices[0]?.currency || "JPY", }; this.logger.log(`Generated invoice stats for user ${userId}`, stats); @@ -387,12 +387,17 @@ export class InvoicesService { */ async getPaymentMethods(userId: string): Promise { try { + this.logger.log(`Starting payment methods retrieval for user ${userId}`); + // Get WHMCS client ID from user mapping const mapping = await this.mappingsService.findByUserId(userId); if (!mapping?.whmcsClientId) { + this.logger.error(`No WHMCS client mapping found for user ${userId}`); throw new NotFoundException("WHMCS client mapping not found"); } + this.logger.log(`Found WHMCS client ID ${mapping.whmcsClientId} for user ${userId}`); + // Fetch payment methods from WHMCS const paymentMethods = await this.whmcsService.getPaymentMethods( mapping.whmcsClientId, @@ -400,12 +405,15 @@ export class InvoicesService { ); this.logger.log( - `Retrieved ${paymentMethods.paymentMethods.length} payment methods for user ${userId}` + `Retrieved ${paymentMethods.paymentMethods.length} payment methods for user ${userId} (client ${mapping.whmcsClientId})` ); return paymentMethods; } catch (error) { this.logger.error(`Failed to get payment methods for user ${userId}`, { error: getErrorMessage(error), + errorType: error instanceof Error ? error.constructor.name : typeof error, + errorMessage: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, }); if (error instanceof NotFoundException) { @@ -416,6 +424,29 @@ export class InvoicesService { } } + /** + * Invalidate payment methods cache for a user + */ + async invalidatePaymentMethodsCache(userId: string): Promise { + try { + // Get WHMCS client ID from user mapping + const mapping = await this.mappingsService.findByUserId(userId); + if (!mapping?.whmcsClientId) { + throw new NotFoundException("WHMCS client mapping not found"); + } + + // Invalidate WHMCS payment methods cache + await this.whmcsService.invalidatePaymentMethodsCache(userId); + + this.logger.log(`Invalidated payment methods cache for user ${userId}`); + } catch (error) { + this.logger.error(`Failed to invalidate payment methods cache for user ${userId}`, { + error: getErrorMessage(error), + }); + throw new Error(`Failed to invalidate payment methods cache: ${getErrorMessage(error)}`); + } + } + /** * Get available payment gateways */ diff --git a/apps/bff/src/mappings/mappings.service.ts b/apps/bff/src/mappings/mappings.service.ts index 7b481867..9fd8938e 100644 --- a/apps/bff/src/mappings/mappings.service.ts +++ b/apps/bff/src/mappings/mappings.service.ts @@ -89,6 +89,59 @@ export class MappingsService { } } + /** + * Find mapping by Salesforce Account ID + */ + async findBySfAccountId(sfAccountId: string): Promise { + try { + // Validate SF Account ID + if (!sfAccountId) { + throw new BadRequestException("Salesforce Account ID is required"); + } + + // Try cache first (check all cached mappings) + const allCached = await this.getAllMappingsFromDb(); + const cachedMapping = allCached.find((m: UserIdMapping) => m.sfAccountId === sfAccountId); + if (cachedMapping) { + this.logger.debug(`Cache hit for SF account mapping: ${sfAccountId}`); + return cachedMapping; + } + + // Fetch from database + const dbMapping = await this.prisma.idMapping.findFirst({ + where: { sfAccountId }, + }); + + if (!dbMapping) { + this.logger.debug(`No mapping found for SF account ${sfAccountId}`); + return null; + } + + const mapping: UserIdMapping = { + userId: dbMapping.userId, + whmcsClientId: dbMapping.whmcsClientId, + sfAccountId: dbMapping.sfAccountId || undefined, + createdAt: dbMapping.createdAt, + updatedAt: dbMapping.updatedAt, + }; + + // Cache the result + await this.cacheService.setMapping(mapping); + + this.logger.debug(`Found mapping for SF account ${sfAccountId}`, { + userId: mapping.userId, + whmcsClientId: mapping.whmcsClientId, + }); + + return mapping; + } catch (error) { + this.logger.error(`Failed to find mapping for SF account ${sfAccountId}`, { + error: getErrorMessage(error), + }); + throw error; + } + } + /** * Find mapping by user ID */ @@ -193,57 +246,7 @@ export class MappingsService { } } - /** - * Find mapping by Salesforce account ID - */ - async findBySfAccountId(sfAccountId: string): Promise { - try { - // Validate Salesforce account ID - if (!sfAccountId) { - throw new BadRequestException("Salesforce account ID is required"); - } - // Try cache first - const cached = await this.cacheService.getBySfAccountId(sfAccountId); - if (cached) { - this.logger.debug(`Cache hit for SF account mapping: ${sfAccountId}`); - return cached; - } - - // Fetch from database - const dbMapping = await this.prisma.idMapping.findFirst({ - where: { sfAccountId }, - }); - - if (!dbMapping) { - this.logger.debug(`No mapping found for SF account ${sfAccountId}`); - return null; - } - - const mapping: UserIdMapping = { - userId: dbMapping.userId, - whmcsClientId: dbMapping.whmcsClientId, - sfAccountId: dbMapping.sfAccountId || undefined, - createdAt: dbMapping.createdAt, - updatedAt: dbMapping.updatedAt, - }; - - // Cache the result - await this.cacheService.setMapping(mapping); - - this.logger.debug(`Found mapping for SF account ${sfAccountId}`, { - userId: mapping.userId, - whmcsClientId: mapping.whmcsClientId, - }); - - return mapping; - } catch (error) { - this.logger.error(`Failed to find mapping for SF account ${sfAccountId}`, { - error: getErrorMessage(error), - }); - throw error; - } - } /** * Update an existing mapping diff --git a/apps/bff/src/subscriptions/subscriptions.service.ts b/apps/bff/src/subscriptions/subscriptions.service.ts index c2137572..0cb14db9 100644 --- a/apps/bff/src/subscriptions/subscriptions.service.ts +++ b/apps/bff/src/subscriptions/subscriptions.service.ts @@ -218,7 +218,7 @@ export class SubscriptionsService { completed: subscriptions.filter(s => s.status === "Completed").length, totalMonthlyRevenue, activeMonthlyRevenue, - currency: subscriptions[0]?.currency || "USD", + currency: subscriptions[0]?.currency || "JPY", }; this.logger.log(`Generated subscription stats for user ${userId}`, { diff --git a/apps/bff/src/vendors/salesforce/salesforce.service.ts b/apps/bff/src/vendors/salesforce/salesforce.service.ts index 8ae30b42..9162a53f 100644 --- a/apps/bff/src/vendors/salesforce/salesforce.service.ts +++ b/apps/bff/src/vendors/salesforce/salesforce.service.ts @@ -62,6 +62,14 @@ export class SalesforceService implements OnModuleInit { return this.accountService.findByCustomerNumber(customerNumber); } + async getAccountDetails(accountId: string): Promise<{ id: string; WH_Account__c?: string; Name?: string } | null> { + return this.accountService.getAccountDetails(accountId); + } + + async updateWhAccount(accountId: string, whAccountValue: string): Promise { + return this.accountService.updateWhAccount(accountId, whAccountValue); + } + async upsertAccount(accountData: AccountData): Promise { return this.accountService.upsert(accountData); } diff --git a/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts b/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts index 483db804..92ca04c1 100644 --- a/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts +++ b/apps/bff/src/vendors/salesforce/services/salesforce-account.service.ts @@ -28,6 +28,7 @@ interface SalesforceQueryResult { interface SalesforceAccount { Id: string; Name: string; + WH_Account__c?: string; } interface SalesforceCreateResult { @@ -58,6 +59,61 @@ export class SalesforceAccountService { } } + async getAccountDetails(accountId: string): Promise<{ id: string; WH_Account__c?: string; Name?: string } | null> { + if (!accountId?.trim()) throw new Error("Account ID is required"); + + try { + const result = (await this.connection.query( + `SELECT Id, Name, WH_Account__c FROM Account WHERE Id = '${this.safeSoql(accountId.trim())}'` + )) as SalesforceQueryResult; + + if (result.totalSize === 0) { + return null; + } + + const record = result.records[0]; + return { + id: record.Id, + Name: record.Name, + WH_Account__c: record.WH_Account__c || undefined, + }; + } catch (error) { + this.logger.error("Failed to get account details", { + accountId, + error: getErrorMessage(error), + }); + throw new Error("Failed to get account details"); + } + } + + async updateWhAccount(accountId: string, whAccountValue: string): Promise { + if (!accountId?.trim()) throw new Error("Account ID is required"); + if (!whAccountValue?.trim()) throw new Error("WH Account value is required"); + + try { + const sobject = this.connection.sobject("Account") as unknown as { + update: (data: Record) => Promise; + }; + + await sobject.update({ + Id: accountId.trim(), + WH_Account__c: whAccountValue.trim(), + }); + + this.logger.log("Updated WH Account field", { + accountId, + whAccountValue, + }); + } catch (error) { + this.logger.error("Failed to update WH Account field", { + accountId, + whAccountValue, + error: getErrorMessage(error), + }); + throw new Error("Failed to update WH Account field"); + } + } + async upsert(accountData: AccountData): Promise { if (!accountData.name?.trim()) throw new Error("Account name is required"); diff --git a/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts b/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts index dae345a6..cf6f173b 100644 --- a/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts +++ b/apps/bff/src/vendors/whmcs/services/whmcs-payment.service.ts @@ -29,10 +29,36 @@ export class WhmcsPaymentService { } const response = await this.connectionService.getPayMethods({ clientid: clientId }); - const methods = (response.paymethods?.paymethod || []).map(pm => - this.dataTransformer.transformPaymentMethod(pm) - ); + + // Debug logging to understand what WHMCS returns + this.logger.log(`WHMCS GetPayMethods response for client ${clientId}:`, { + rawResponse: response, + paymethods: response.paymethods, + paymethod: response.paymethods?.paymethod, + isArray: Array.isArray(response.paymethods?.paymethod), + length: response.paymethods?.paymethod?.length, + userId + }); + + const methods = (response.paymethods?.paymethod || []).map(pm => { + const transformed = this.dataTransformer.transformPaymentMethod(pm); + this.logger.log(`Transformed payment method:`, { + original: pm, + transformed, + clientId, + userId + }); + return transformed; + }); + const result: PaymentMethodList = { paymentMethods: methods, totalCount: methods.length }; + + this.logger.log(`Final payment methods result for client ${clientId}:`, { + totalCount: result.totalCount, + methods: result.paymentMethods, + userId + }); + await this.cacheService.setPaymentMethods(userId, result); return result; } catch (error) { @@ -167,6 +193,21 @@ export class WhmcsPaymentService { } } + /** + * Invalidate payment methods cache for a user + */ + async invalidatePaymentMethodsCache(userId: string): Promise { + try { + await this.cacheService.invalidatePaymentMethods(userId); + this.logger.log(`Invalidated payment methods cache for user ${userId}`); + } catch (error) { + this.logger.error(`Failed to invalidate payment methods cache for user ${userId}`, { + error: getErrorMessage(error), + }); + throw error; + } + } + /** * Transform product data (delegate to transformer) */ diff --git a/apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts b/apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts index 14d6fc17..17b063df 100644 --- a/apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts +++ b/apps/bff/src/vendors/whmcs/transformers/whmcs-data.transformer.ts @@ -43,9 +43,9 @@ export class WhmcsDataTransformer { id: Number(invoiceId), number: whmcsInvoice.invoicenum || `INV-${invoiceId}`, status: this.normalizeInvoiceStatus(whmcsInvoice.status), - currency: whmcsInvoice.currencycode || "USD", + currency: whmcsInvoice.currencycode || "JPY", currencySymbol: - whmcsInvoice.currencyprefix || this.getCurrencySymbol(whmcsInvoice.currencycode || "USD"), + whmcsInvoice.currencyprefix || this.getCurrencySymbol(whmcsInvoice.currencycode || "JPY"), total: this.parseAmount(whmcsInvoice.total), subtotal: this.parseAmount(whmcsInvoice.subtotal), tax: this.parseAmount(whmcsInvoice.tax) + this.parseAmount(whmcsInvoice.tax2), @@ -92,7 +92,7 @@ export class WhmcsDataTransformer { status: this.normalizeProductStatus(whmcsProduct.status), nextDue: this.formatDate(whmcsProduct.nextduedate), amount: this.getProductAmount(whmcsProduct), - currency: whmcsProduct.currencycode || "USD", + currency: whmcsProduct.currencycode || "JPY", registrationDate: this.formatDate(whmcsProduct.regdate) || new Date().toISOString().split("T")[0], @@ -346,7 +346,7 @@ export class WhmcsDataTransformer { USD: "$", EUR: "€", GBP: "£", - JPY: "¥", + JPY: "¥", CAD: "C$", AUD: "A$", CNY: "¥", @@ -378,7 +378,7 @@ export class WhmcsDataTransformer { NZD: "NZ$", }; - return currencyMap[currencyCode?.toUpperCase()] || currencyCode || "$"; + return currencyMap[currencyCode?.toUpperCase()] || currencyCode || "¥"; } /** diff --git a/apps/portal/src/app/api/auth/login/route.ts b/apps/portal/src/app/api/auth/login/route.ts new file mode 100644 index 00000000..c380dbd2 --- /dev/null +++ b/apps/portal/src/app/api/auth/login/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Forward the request to the BFF + const bffUrl = process.env.NEXT_PUBLIC_BFF_URL || 'http://localhost:4000'; + const response = await fetch(`${bffUrl}/api/auth/login`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Forward any relevant headers + 'User-Agent': request.headers.get('user-agent') || '', + 'X-Forwarded-For': request.headers.get('x-forwarded-for') || '', + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + // Return the response with the same status code + return NextResponse.json(data, { status: response.status }); + } catch (error) { + console.error('Login API error:', error); + return NextResponse.json( + { message: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/apps/portal/src/app/api/auth/signup/route.ts b/apps/portal/src/app/api/auth/signup/route.ts new file mode 100644 index 00000000..7f106a89 --- /dev/null +++ b/apps/portal/src/app/api/auth/signup/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Forward the request to the BFF + const bffUrl = process.env.NEXT_PUBLIC_BFF_URL || 'http://localhost:4000'; + const response = await fetch(`${bffUrl}/api/auth/signup`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Forward any relevant headers + 'User-Agent': request.headers.get('user-agent') || '', + 'X-Forwarded-For': request.headers.get('x-forwarded-for') || '', + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + // Return the response with the same status code + return NextResponse.json(data, { status: response.status }); + } catch (error) { + console.error('Signup API error:', error); + return NextResponse.json( + { message: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/apps/portal/src/app/api/auth/validate-signup/route.ts b/apps/portal/src/app/api/auth/validate-signup/route.ts new file mode 100644 index 00000000..8909e6f2 --- /dev/null +++ b/apps/portal/src/app/api/auth/validate-signup/route.ts @@ -0,0 +1,31 @@ +import { NextRequest, NextResponse } from 'next/server'; + +export async function POST(request: NextRequest) { + try { + const body = await request.json(); + + // Forward the request to the BFF + const bffUrl = process.env.NEXT_PUBLIC_BFF_URL || 'http://localhost:4000'; + const response = await fetch(`${bffUrl}/api/auth/validate-signup`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + // Forward any relevant headers + 'User-Agent': request.headers.get('user-agent') || '', + 'X-Forwarded-For': request.headers.get('x-forwarded-for') || '', + }, + body: JSON.stringify(body), + }); + + const data = await response.json(); + + // Return the response with the same status code + return NextResponse.json(data, { status: response.status }); + } catch (error) { + console.error('Validate signup API error:', error); + return NextResponse.json( + { message: 'Internal server error' }, + { status: 500 } + ); + } +} diff --git a/apps/portal/src/app/auth/signup/page.tsx b/apps/portal/src/app/auth/signup/page.tsx index 38f6341e..3aa00ba3 100644 --- a/apps/portal/src/app/auth/signup/page.tsx +++ b/apps/portal/src/app/auth/signup/page.tsx @@ -11,344 +11,649 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useAuthStore } from "@/lib/auth/store"; +import { CheckCircle, XCircle, ArrowRight, ArrowLeft } from "lucide-react"; -const signupSchema = z - .object({ - email: z.string().email("Please enter a valid email address"), - confirmEmail: z.string().email("Please confirm with a valid email"), - password: z - .string() - .min(8, "Password must be at least 8 characters") - .regex( - /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, - "Password must contain uppercase, lowercase, number, and special character" - ), - confirmPassword: z.string(), - firstName: z.string().min(1, "First name is required"), - lastName: z.string().min(1, "Last name is required"), - company: z.string().optional(), - phone: z.string().optional(), - sfNumber: z.string().min(1, "Customer Number is required"), - addressLine1: z.string().optional(), - addressLine2: z.string().optional(), - city: z.string().optional(), - state: z.string().optional(), - postalCode: z.string().optional(), - country: z.string().optional(), - nationality: z.string().optional(), - dateOfBirth: z.string().optional(), - gender: z.enum(["male", "female", "other"]).optional(), - }) - .refine(values => values.email === values.confirmEmail, { - message: "Emails do not match", - path: ["confirmEmail"], - }) - .refine(values => values.password === values.confirmPassword, { - message: "Passwords do not match", - path: ["confirmPassword"], - }); +// Step 1: Customer Number Validation Schema +const step1Schema = z.object({ + sfNumber: z.string().min(1, "Customer Number is required"), +}); -type SignupForm = z.infer; +// Step 2: Personal Information Schema +const step2Schema = z.object({ + firstName: z.string().min(1, "First name is required"), + lastName: z.string().min(1, "Last name is required"), + email: z.string().email("Please enter a valid email address"), + confirmEmail: z.string().email("Please confirm with a valid email"), + password: z + .string() + .min(8, "Password must be at least 8 characters") + .regex( + /^(?=.*[a-z])(?=.*[A-Z])(?=.*\d)(?=.*[@$!%*?&])[A-Za-z\d@$!%*?&]/, + "Password must contain uppercase, lowercase, number, and special character" + ), + confirmPassword: z.string(), +}).refine(values => values.email === values.confirmEmail, { + message: "Emails do not match", + path: ["confirmEmail"], +}).refine(values => values.password === values.confirmPassword, { + message: "Passwords do not match", + path: ["confirmPassword"], +}); + +// Step 3: Contact & Address Schema +const step3Schema = z.object({ + company: z.string().optional(), + phone: z.string().min(1, "Phone number is required"), + addressLine1: z.string().min(1, "Address is required"), + addressLine2: z.string().optional(), + city: z.string().min(1, "City is required"), + state: z.string().min(1, "State/Prefecture is required"), + postalCode: z.string().min(1, "Postal code is required"), + country: z.string().min(2, "Please select a valid country").max(2, "Please select a valid country"), + nationality: z.string().optional(), + dateOfBirth: z.string().optional(), + gender: z.enum(["male", "female", "other"]).optional(), +}); + +type Step1Form = z.infer; +type Step2Form = z.infer; +type Step3Form = z.infer; + +interface SignupData { + sfNumber: string; + firstName: string; + lastName: string; + email: string; + password: string; + company?: string; + phone: string; + addressLine1: string; + addressLine2?: string; + city: string; + state: string; + postalCode: string; + country: string; + nationality?: string; + dateOfBirth?: string; + gender?: "male" | "female" | "other"; +} export default function SignupPage() { const router = useRouter(); const { signup, isLoading } = useAuthStore(); + const [currentStep, setCurrentStep] = useState(1); const [error, setError] = useState(null); + const [validationStatus, setValidationStatus] = useState<{ + sfNumberValid: boolean; + whAccountValid: boolean; + sfAccountId?: string; + } | null>(null); - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(signupSchema), + // Step 1 Form + const step1Form = useForm({ + resolver: zodResolver(step1Schema), }); - const onSubmit = async (data: SignupForm) => { + // Step 2 Form + const step2Form = useForm({ + resolver: zodResolver(step2Schema), + }); + + // Step 3 Form + const step3Form = useForm({ + resolver: zodResolver(step3Schema), + }); + + // Step 1: Validate Customer Number + const onStep1Submit = async (data: Step1Form) => { try { setError(null); - await signup({ - email: data.email, - password: data.password, - firstName: data.firstName, - lastName: data.lastName, + setValidationStatus(null); + + // Call backend to validate SF number and WH Account field + const response = await fetch("/api/auth/validate-signup", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ sfNumber: data.sfNumber }), + }); + + const result = await response.json(); + + if (!response.ok) { + if (response.status === 409) { + // User already has account + setError("You already have an account. Please use the login page to access your existing account."); + return; + } + throw new Error(result.message || "Validation failed"); + } + + setValidationStatus({ + sfNumberValid: true, + whAccountValid: true, + sfAccountId: result.sfAccountId, + }); + + setCurrentStep(2); + } catch (err) { + setError(err instanceof Error ? err.message : "Validation failed"); + } + }; + + // Step 2: Personal Information + const onStep2Submit = (data: Step2Form) => { + setCurrentStep(3); + }; + + // Step 3: Contact & Address + const onStep3Submit = async (data: Step3Form) => { + try { + setError(null); + console.log("Step 3 form data:", data); + console.log("Step 1 data:", step1Form.getValues()); + console.log("Step 2 data:", step2Form.getValues()); + + const signupData: SignupData = { + sfNumber: step1Form.getValues("sfNumber"), + firstName: step2Form.getValues("firstName"), + lastName: step2Form.getValues("lastName"), + email: step2Form.getValues("email"), + password: step2Form.getValues("password"), company: data.company, phone: data.phone, - sfNumber: data.sfNumber, - address: - data.addressLine1 || data.city || data.state || data.postalCode || data.country - ? { - line1: data.addressLine1 || "", - line2: data.addressLine2 || undefined, - city: data.city || "", - state: data.state || "", - postalCode: data.postalCode || "", - country: data.country || "", - } - : undefined, - nationality: data.nationality || undefined, - dateOfBirth: data.dateOfBirth || undefined, - gender: data.gender || undefined, + addressLine1: data.addressLine1, + addressLine2: data.addressLine2, + city: data.city, + state: data.state, + postalCode: data.postalCode, + country: data.country, + nationality: data.nationality, + dateOfBirth: data.dateOfBirth, + gender: data.gender, + }; + + await signup({ + email: signupData.email, + password: signupData.password, + firstName: signupData.firstName, + lastName: signupData.lastName, + company: signupData.company, + phone: signupData.phone, + sfNumber: signupData.sfNumber, + address: { + line1: signupData.addressLine1, + line2: signupData.addressLine2, + city: signupData.city, + state: signupData.state, + postalCode: signupData.postalCode, + country: signupData.country, + }, + nationality: signupData.nationality, + dateOfBirth: signupData.dateOfBirth, + gender: signupData.gender, }); + router.push("/dashboard"); } catch (err) { setError(err instanceof Error ? err.message : "Signup failed"); } }; - return ( - -
{ - void handleSubmit(onSubmit)(e); - }} - className="space-y-4" - > - {error && ( -
- {error} -
- )} + const goBack = () => { + if (currentStep > 1) { + setCurrentStep(currentStep - 1); + setError(null); + } + }; -
-
- - - {errors.firstName && ( -

{errors.firstName.message}

- )} -
- -
- - - {errors.lastName && ( -

{errors.lastName.message}

- )} -
-
- - {/* Address */} -
- - -
-
- - -
- -
-
- - -
-
- - -
-
- - -
-
- -
-
- - -
-
- - -
-
- -
-
- - - {errors.phone &&

{errors.phone.message}

} -
-
- - -
-
- -
-
- - -
-
- - - {errors.sfNumber && ( -

{errors.sfNumber.message}

+ {step} +
+ {step < 3 && ( +
step ? "bg-blue-600" : "bg-gray-200" + }`} + /> )}
+ ))} +
+ + ); + + const renderStep1 = () => ( + +
+ + + {step1Form.formState.errors.sfNumber && ( +

+ {step1Form.formState.errors.sfNumber.message} +

+ )} +
+ + {validationStatus && ( +
+
+ + Customer number validated successfully +
+

+ Your customer number has been verified and is eligible for account creation. +

-
-
- - - {errors.email &&

{errors.email.message}

} -
-
- - - {errors.confirmEmail && ( -

{errors.confirmEmail.message}

- )} -
+ )} + + + + ); + + const renderStep2 = () => ( +
+
+
+ + + {step2Form.formState.errors.firstName && ( +

+ {step2Form.formState.errors.firstName.message} +

+ )}
+
+ + + {step2Form.formState.errors.lastName && ( +

+ {step2Form.formState.errors.lastName.message} +

+ )} +
+
+ +
+
+ + + {step2Form.formState.errors.email && ( +

+ {step2Form.formState.errors.email.message} +

+ )} +
+ +
+ + + {step2Form.formState.errors.confirmEmail && ( +

+ {step2Form.formState.errors.confirmEmail.message} +

+ )} +
+
+ +
+
+ + + {step2Form.formState.errors.password && ( +

+ {step2Form.formState.errors.password.message} +

+ )} +

+ Must be at least 8 characters with uppercase, lowercase, number, and special character +

+
+ +
+ + + {step2Form.formState.errors.confirmPassword && ( +

+ {step2Form.formState.errors.confirmPassword.message} +

+ )} +
+
+ +
+ + +
+
+ ); + + const renderStep3 = () => ( +
{ + console.log("Step 3 form submit triggered"); + console.log("Form errors:", step3Form.formState.errors); + step3Form.handleSubmit(onStep3Submit)(e); + }} className="space-y-6"> +
- {errors.company &&

{errors.company.message}

}
- + - {errors.phone &&

{errors.phone.message}

} -
- -
-
- - - {errors.password && ( -

{errors.password.message}

- )} -

- Must be at least 8 characters with uppercase, lowercase, number, and special character + {step3Form.formState.errors.phone && ( +

+ {step3Form.formState.errors.phone.message}

-
-
- - - {errors.confirmPassword && ( -

{errors.confirmPassword.message}

- )} -
+ )} +
+
+ +
+ + + {step3Form.formState.errors.addressLine1 && ( +

+ {step3Form.formState.errors.addressLine1.message} +

+ )} +
+ +
+ + +
+ +
+
+ + + {step3Form.formState.errors.city && ( +

+ {step3Form.formState.errors.city.message} +

+ )}
-
+ +
+
+ + + {step3Form.formState.errors.country && ( +

+ {step3Form.formState.errors.country.message} +

+ )} +
+ +
+ + +
+
+ +
+
+ + +
+ +
+ + +
+
+ +
+ + +
+
+ ); -
-

- Already have an account?{" "} - - Sign in here - -

-

- Already a customer?{" "} - - Transfer your existing account - -

+ return ( + + {renderStepIndicator()} + + {error && ( +
+
+ + {error} +
+ {error.includes("already have an account") && ( +
+ + Go to login page + +
+ )}
- + )} + + {currentStep === 1 && renderStep1()} + {currentStep === 2 && renderStep2()} + {currentStep === 3 && renderStep3()} + +
+

+ Already have an account?{" "} + + Sign in here + +

+

+ Already a customer?{" "} + + Transfer your existing account + +

+
); } diff --git a/apps/portal/src/app/catalog/internet/page.tsx b/apps/portal/src/app/catalog/internet/page.tsx index 40ce9067..cbaca8d0 100644 --- a/apps/portal/src/app/catalog/internet/page.tsx +++ b/apps/portal/src/app/catalog/internet/page.tsx @@ -131,74 +131,46 @@ export default function InternetPlansPage() { Available for: {eligibility}

- Plans shown are tailored to your dwelling type and local infrastructure + Plans shown are tailored to your house type and local infrastructure

)} - {/* Plan Comparison */} - {plans.length > 1 && ( -
-

Plan Comparison

-
- {/* Silver Plan */} - {plans.some(p => p.tier === "Silver") && ( -
-
-

Silver

- Basic -
-
-
✓ NTT modem
-
⚠ Router required
-
• Customer setup
-
-
- )} - {/* Gold Plan */} - {plans.some(p => p.tier === "Gold") && ( -
-
-

Gold

- - Recommended - -
-
-
✓ Complete router solution
-
✓ Professional setup
-
✓ Optimal performance
-
-
- )} - - {/* Platinum Plan */} - {plans.some(p => p.tier === "Platinum") && ( -
-
-

Platinum

- Residences >50㎡ -
-
-
✓ INSIGHT management
-
✓ Multiple routers
-
💰 ¥500/month cloud management fee per router
-
-
- )} -
-
- )} {/* Plans Grid */} {plans.length > 0 ? ( -
- {plans.map(plan => ( - - ))} -
+ <> +
+ {plans.map(plan => ( + + ))} +
+ + {/* Important Notes */} +
+

Important Notes:

+
    +
  • + + Theoretical internet speed is the same for all three packages +
  • +
  • + + One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments +
  • +
  • + + Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans (¥450/month + ¥1,000-3,000 one-time) +
  • +
  • + + In-home technical assistance available (¥15,000 onsite visiting fee) +
  • +
+
+ ) : (
@@ -220,11 +192,21 @@ export default function InternetPlansPage() { function InternetPlanCard({ plan }: { plan: InternetPlan }) { const isGold = plan.tier === "Gold"; const isPlatinum = plan.tier === "Platinum"; + const isSilver = plan.tier === "Silver"; - const cardVariant = isGold ? "success" : isPlatinum ? "highlighted" : "default"; + // Use default variant for all cards to avoid green background on gold + const cardVariant = "default"; + + // Custom border colors for each tier + const getBorderClass = () => { + if (isGold) return "border-2 border-yellow-400 shadow-lg hover:shadow-xl"; + if (isPlatinum) return "border-2 border-indigo-400 shadow-lg hover:shadow-xl"; + if (isSilver) return "border-2 border-gray-300 shadow-lg hover:shadow-xl"; + return "border border-gray-200 shadow-lg hover:shadow-xl"; + }; return ( - +
{/* Header */}
@@ -260,30 +242,42 @@ function InternetPlanCard({ plan }: { plan: InternetPlan }) {
{/* Plan Details */} -

{plan.name}

-

{plan.tierDescription}

+

+ {plan.name} +

+

+ {plan.tierDescription || plan.description} +

{/* Your Plan Includes */}

Your Plan Includes:

    -
  • - 1 NTT Optical Fiber (Flet's Hikari - Next - {plan.offeringType?.includes("Apartment") ? "Mansion" : "Home"}{" "} - {plan.offeringType?.includes("10G") - ? "10Gbps" - : plan.offeringType?.includes("100M") - ? "100Mbps" - : "1Gbps"} - ) Installation + Monthly -
  • - - {plan.features.map((feature, index) => ( -
  • - - {feature} -
  • - ))} + {plan.features && plan.features.length > 0 ? ( + plan.features.map((feature, index) => ( +
  • + + {feature} +
  • + )) + ) : ( + <> +
  • + 1 NTT Optical Fiber (Flet's Hikari + Next - {plan.offeringType?.includes("Apartment") ? "Mansion" : "Home"}{" "} + {plan.offeringType?.includes("10G") + ? "10Gbps" + : plan.offeringType?.includes("100M") + ? "100Mbps" + : "1Gbps"} + ) Installation + Monthly +
  • +
  • + + Monthly: ¥{plan.monthlyPrice?.toLocaleString()} | One-time: ¥{plan.setupFee?.toLocaleString() || '22,800'} +
  • + + )}
diff --git a/apps/portal/src/app/catalog/page.tsx b/apps/portal/src/app/catalog/page.tsx index 45ad2ae7..76c1a37e 100644 --- a/apps/portal/src/app/catalog/page.tsx +++ b/apps/portal/src/app/catalog/page.tsx @@ -99,7 +99,7 @@ export default function CatalogPage() { } title="Location-Based Plans" - description="Internet plans tailored to your dwelling type and available infrastructure" + description="Internet plans tailored to your house type and available infrastructure" /> } diff --git a/apps/portal/src/app/catalog/sim/page.tsx b/apps/portal/src/app/catalog/sim/page.tsx index 36a4dace..52596938 100644 --- a/apps/portal/src/app/catalog/sim/page.tsx +++ b/apps/portal/src/app/catalog/sim/page.tsx @@ -45,7 +45,7 @@ function PlanTypeSection({ const familyPlans = plans.filter(p => p.hasFamilyDiscount); return ( -
+
{icon}
@@ -55,7 +55,7 @@ function PlanTypeSection({
{/* Regular Plans */} -
+
{regularPlans.map(plan => ( ))} @@ -71,7 +71,7 @@ function PlanTypeSection({ You qualify!
-
+
{familyPlans.map(plan => ( ))} @@ -84,7 +84,7 @@ function PlanTypeSection({ function PlanCard({ plan, isFamily }: { plan: SimPlan; isFamily: boolean }) { return ( - +
@@ -131,6 +131,7 @@ export default function SimPlansPage() { const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [hasExistingSim, setHasExistingSim] = useState(false); + const [activeTab, setActiveTab] = useState<'data-voice' | 'data-only' | 'voice-only'>('data-voice'); useEffect(() => { let mounted = true; @@ -209,9 +210,9 @@ export default function SimPlansPage() { description="Choose your mobile plan with flexible options" icon={} > -
+
{/* Navigation */} -
+
Back to Services @@ -258,35 +259,97 @@ export default function SimPlansPage() {
)} - {/* Data + Voice Plans (Most Popular) */} - } - plans={plansByType.DataSmsVoice} - showFamilyDiscount={hasExistingSim} - /> + {/* Tab Navigation */} +
+
+ +
+
- {/* Data Only Plans */} - } - plans={plansByType.DataOnly} - showFamilyDiscount={hasExistingSim} - /> + {/* Tab Content */} +
+ {activeTab === 'data-voice' && ( + } + plans={plansByType.DataSmsVoice} + showFamilyDiscount={hasExistingSim} + /> + )} - {/* Voice Only Plans */} - } - plans={plansByType.VoiceOnly} - showFamilyDiscount={hasExistingSim} - /> + {activeTab === 'data-only' && ( + } + plans={plansByType.DataOnly} + showFamilyDiscount={hasExistingSim} + /> + )} + + {activeTab === 'voice-only' && ( + } + plans={plansByType.VoiceOnly} + showFamilyDiscount={hasExistingSim} + /> + )} +
{/* Features Section */} -
+

All SIM Plans Include

@@ -323,7 +386,7 @@ export default function SimPlansPage() {
{/* Info Section */} -
+
Getting Started
diff --git a/apps/portal/src/app/catalog/vpn/page.tsx b/apps/portal/src/app/catalog/vpn/page.tsx index 55da6ef0..88043287 100644 --- a/apps/portal/src/app/catalog/vpn/page.tsx +++ b/apps/portal/src/app/catalog/vpn/page.tsx @@ -104,96 +104,112 @@ export default function VpnPlansPage() {
-

VPN Router Rental

+

SonixNet VPN Rental Router Service

- Secure VPN router rental service for business and personal use with enterprise-grade - security. + Fast and secure VPN connection to San Francisco or London for accessing geo-restricted content.

-
- {vpnPlans.map(plan => { - const activationFee = getActivationFeeForRegion(); + {/* Available Plans Section */} + {vpnPlans.length > 0 ? ( +
+

Available Plans

+

(One region per router)

+ +
+ {vpnPlans.map(plan => { + const activationFee = getActivationFeeForRegion(plan.region); - return ( - -
-
- -
-

{plan.name}

-

VPN Router Rental

+ return ( + +
+

{plan.name}

-
-
-
-
- - - {plan.monthlyPrice?.toLocaleString()} - - /month -
-
- -
-

{plan.description}

- -
-
- - Enterprise-grade security +
+
+ + + {plan.monthlyPrice?.toLocaleString()} + + /month +
-
- - 24/7 monitoring -
-
- - Remote management -
-
-
- {activationFee && ( -
-
Setup Fee
-
- ¥{activationFee.price.toLocaleString()} one-time -
-
- )} + {plan.features && plan.features.length > 0 && ( +
+

Features:

+
    + {plan.features.map((feature, index) => ( +
  • + + {feature} +
  • + ))} +
+
+ )} - - Request Quote - - - ); - })} -
- -
- -

Enterprise VPN Solutions

-

- Secure your business communications with our managed VPN router rental service. Perfect - for companies requiring reliable and secure remote access. -

-
-
-
Secure
-
Bank-level encryption
-
-
-
Managed
-
We handle the setup
-
-
-
Reliable
-
99.9% uptime guarantee
+ + Configure Plan + + + ); + })}
+ + {activationFees.length > 0 && ( +
+

+ A one-time activation fee of 3000 JPY is incurred seprarately for each rental unit. Tax (10%) not included. +

+
+ )} +
+ ) : ( +
+ +

No VPN Plans Available

+

+ We couldn't find any VPN plans available at this time. +

+ + + Back to Services + +
+ )} + + {/* Service Description Section */} +
+

How It Works

+
+

+ SonixNet VPN is the easiest way to access video streaming services from overseas on your network media players such as an Apple TV, Roku, or Amazon Fire. +

+

+ A configured Wi-Fi router is provided for rental (no purchase required, no hidden fees). All you will need to do is to plug the VPN router into your existing internet connection. +

+

+ Then you can connect your network media players to the VPN Wi-Fi network, to connect to the VPN server. +

+

+ For daily Internet usage that does not require a VPN, we recommend connecting to your regular home Wi-Fi. +

+ + {/* Disclaimer Section */} +
+

Important Disclaimer

+

+ *1: Content subscriptions are NOT included in the SonixNet VPN package. Our VPN service will establish a network connection that virtually locates you in the designated server location, then you will sign up for the streaming services of your choice. Not all services/websites can be unblocked. Assist Solutions does not guarantee or bear any responsibility over the unblocking of any websites or the quality of the streaming/browsing. +

+
+ +
); diff --git a/apps/portal/src/app/checkout/page.tsx b/apps/portal/src/app/checkout/page.tsx index 008cde93..b44f7833 100644 --- a/apps/portal/src/app/checkout/page.tsx +++ b/apps/portal/src/app/checkout/page.tsx @@ -3,9 +3,10 @@ import { useState, useEffect, useMemo, Suspense } from "react"; import { useSearchParams, useRouter } from "next/navigation"; import { PageLayout } from "@/components/layout/page-layout"; -import { ShieldCheckIcon } from "@heroicons/react/24/outline"; +import { ShieldCheckIcon, ExclamationTriangleIcon } from "@heroicons/react/24/outline"; import { authenticatedApi } from "@/lib/api"; import { AddressConfirmation } from "@/components/checkout/address-confirmation"; +import { usePaymentMethods } from "@/hooks/useInvoices"; import { InternetPlan, @@ -44,6 +45,9 @@ function CheckoutContent() { totals: { monthlyTotal: 0, oneTimeTotal: 0 }, }); + // Fetch payment methods to check if user has payment method on file + const { data: paymentMethods, isLoading: paymentMethodsLoading, error: paymentMethodsError, refetch: refetchPaymentMethods } = usePaymentMethods(); + const orderType = (() => { const type = params.get("type") || "internet"; // Map to backend expected values @@ -329,26 +333,110 @@ function CheckoutContent() {

Billing Information

-
-
-
- - - -
-
-

Payment method verified

-

- After order approval, payment will be automatically processed using your existing - payment method on file. No additional payment steps required. -

+ {paymentMethodsLoading ? ( +
+
+
+ Checking payment methods...
-
+ ) : paymentMethodsError ? ( +
+
+ +
+

Unable to verify payment methods

+

+ We couldn't check your payment methods. If you just added a payment method, try refreshing. +

+
+ + +
+
+
+
+ ) : paymentMethods && paymentMethods.paymentMethods.length > 0 ? ( +
+
+
+ + + +
+
+

Payment method verified

+

+ After order approval, payment will be automatically processed using your existing + payment method on file. No additional payment steps required. +

+
+
+
+ ) : ( +
+
+ +
+

No payment method on file

+

+ You need to add a payment method before submitting your order. Please add a credit card or other payment method to proceed. +

+ +
+
+
+ )} +
+ + {/* Debug Info - Remove in production */} +
+ Debug Info: Address Confirmed: {addressConfirmed ? '✅' : '❌'} | + Payment Methods: {paymentMethodsLoading ? '⏳ Loading...' : paymentMethodsError ? '❌ Error' : paymentMethods ? `✅ ${paymentMethods.paymentMethods.length} found` : '❌ None'} | + Order Items: {checkoutState.orderItems.length} | + Can Submit: {!( + submitting || + checkoutState.orderItems.length === 0 || + !addressConfirmed || + paymentMethodsLoading || + !paymentMethods || + paymentMethods.paymentMethods.length === 0 + ) ? '✅' : '❌'}
@@ -373,7 +461,14 @@ function CheckoutContent() { Order Services @@ -157,7 +152,7 @@ export default function DashboardPage() { title="Recent Orders" value={((summary?.stats as Record)?.recentOrders as number) || 0} icon={ClipboardDocumentListIconSolid} - gradient="from-indigo-500 to-purple-500" + gradient="from-gray-500 to-gray-600" href="/orders" /> 0 - ? "from-red-500 to-pink-500" - : "from-green-500 to-emerald-500" + ? "from-amber-500 to-orange-500" + : "from-gray-500 to-gray-600" } href="/billing/invoices" /> @@ -177,8 +172,8 @@ export default function DashboardPage() { icon={ChatBubbleLeftRightIconSolid} gradient={ (summary?.stats?.openCases ?? 0) > 0 - ? "from-amber-500 to-orange-500" - : "from-green-500 to-emerald-500" + ? "from-blue-500 to-cyan-500" + : "from-gray-500 to-gray-600" } href="/support/cases" /> @@ -190,14 +185,14 @@ export default function DashboardPage() { {/* Next Invoice Due - Enhanced */} {summary?.nextInvoice && (
-
+

Upcoming Payment

-

+

Don't forget your next payment

@@ -225,7 +220,7 @@ export default function DashboardPage() {
- - {/* Account Status */} -
diff --git a/apps/portal/src/app/page.tsx b/apps/portal/src/app/page.tsx index a8e9f64d..a47e1a02 100644 --- a/apps/portal/src/app/page.tsx +++ b/apps/portal/src/app/page.tsx @@ -1,16 +1,26 @@ import Link from "next/link"; +import { Logo } from "@/components/ui/logo"; +import { + ArrowPathIcon, + UserIcon, + SparklesIcon, + CreditCardIcon, + Cog6ToothIcon, + PhoneIcon, + ChartBarIcon, + ChatBubbleLeftRightIcon, + EnvelopeIcon, +} from "@heroicons/react/24/outline"; export default function Home() { return ( -
+
{/* Header */} -
+
-
- AS -
+

Assist Solutions

Customer Portal

@@ -32,83 +42,58 @@ export default function Home() {
- {/* Hero Section */} -
-
-
-
- ✨ New Portal Available -
-

- New Assist Solutions Customer Portal -

-

- Experience our completely redesigned customer portal with enhanced features, better - performance, and improved user experience. -

-

- Modern Interface • Enhanced Security • 24/7 Availability • English Support -

+ {/* Hero Section */} +
+ {/* Abstract background elements */} +
+
+
+
+
+ +
+
+

+ New Assist Solutions Customer Portal +

-
-
-
+
{/* Customer Portal Access Section */} -
+

Access Your Portal

Choose the option that applies to you

-
+
{/* Existing Customers - Migration */} -
+
- 🔄 +

Existing Customers

- Already have an account with us? Migrate to our new, improved portal to enjoy - enhanced features, better security, and a modern interface. + Migrate to our new portal and enjoy enhanced security with modern interface.

-
-

- Migration Benefits: -

-
-
    -
  • - - Keep all your existing services and billing history -
  • -
  • - - Enhanced security and performance -
  • -
  • - - Modern, mobile-friendly interface -
  • -
-
-
-
Migrate Your Account @@ -118,41 +103,20 @@ export default function Home() {
{/* Portal Users */} -
+
-
- 👤 +
+

Portal Users

- Already migrated or have a new portal account? Sign in to access your dashboard, - manage services, and view billing. + Sign in to access your dashboard and manage all your services efficiently.

-
-

Portal Features:

-
-
    -
  • - - Real-time service status and usage -
  • -
  • - - Online billing and payment management -
  • -
  • - - 24/7 support ticket system -
  • -
-
-
-
Login to Portal @@ -162,43 +126,20 @@ export default function Home() {
{/* New Customers */} -
+
-
- +
+

New Customers

- Ready to get started with our services? Create your account to access our full - range of IT solutions. + Create your account and access our full range of IT solutions and services.

-
-

- Get Started With: -

-
-
    -
  • - - Internet & connectivity solutions -
  • -
  • - - Business IT services -
  • -
  • - - Professional support -
  • -
-
-
-
Create Account @@ -211,8 +152,8 @@ export default function Home() {
{/* Portal Features Section */} -
-
+
+

Portal Features

@@ -221,28 +162,36 @@ export default function Home() {

-
-
💳
+
+
+ +

Billing & Payments

View invoices, payment history, and manage billing

-
-
⚙️
+
+
+ +

Service Management

Control and configure your active services

-
-
📞
+
+
+ +

Support Tickets

Create and track support requests

-
-
📊
+
+
+ +

Usage Reports

Monitor service usage and performance

@@ -250,79 +199,66 @@ export default function Home() {
- {/* Contact Section */} -
+ {/* Support Section */} +

Need Help?

Our support team is here to assist you

-
-
-
- 📞 +
+ {/* Contact Details Box */} +
+

Contact Details

+
+
+

Phone Support

+

9:30-18:00 JST

+

0120-660-470

+

Toll Free within Japan

+
+ +
+

Email Support

+

Response within 24h

+ + Send Message + +
-

Phone Support

-

9:30-18:00 JST

-

0120-660-470

-

Toll Free within Japan

-
-
- 💬 -
-

Live Chat

-

Available 24/7

- - Start Chat - -
+ {/* Live Chat & Business Hours Box */} +
+

Live Chat & Business Hours

+
+
+

Live Chat

+

Available 24/7

+ + Start Chat + +
-
-
- ✉️ +
+

Business Hours

+
+

Monday - Saturday:
10:00 AM - 6:00 PM JST

+

Sunday:
Closed

+
+
-

Email Support

-

Response within 24h

- - Send Message -
{/* Footer */} -