Refactor mappers and services for improved type safety and code clarity

- Updated export statements in user and mapping mappers for consistency.
- Enhanced FreebitAuthService to explicitly define response types for better type inference.
- Refactored various services to improve error handling and response structure.
- Cleaned up unused code and comments across multiple files to enhance readability.
- Improved type annotations in invoice and subscription services for better validation and consistency.
This commit is contained in:
barsa 2025-10-22 10:58:16 +09:00
parent 6567bc5907
commit e5ce4e166c
111 changed files with 1091 additions and 823 deletions

374
DOMAIN_VIOLATIONS_REPORT.md Normal file
View File

@ -0,0 +1,374 @@
# Domain Violations Report
**Customer Portal - BFF and Portal Domain Violations Analysis**
---
## Executive Summary
This report identifies cases where the BFF (Backend for Frontend) and Portal applications are not following the domain package as the single source of truth for types, validation, and business logic. The analysis reveals several categories of violations that need to be addressed to maintain clean architecture principles.
## Architecture Overview
The customer portal follows a clean architecture pattern with:
- **Domain Package** (`packages/domain/`): Single source of truth for types, schemas, and business logic
- **BFF** (`apps/bff/`): NestJS backend that should consume domain types
- **Portal** (`apps/portal/`): Next.js frontend that should consume domain types
## Key Findings
### ✅ **Good Practices Found**
1. **Domain Package Structure**: Well-organized with clear separation of concerns
2. **Type Imports**: Most services correctly import domain types
3. **Validation Patterns**: Many components properly extend domain schemas
4. **Provider Adapters**: Clean separation between domain contracts and provider implementations
### ❌ **Domain Violations Identified**
## 1. BFF Layer Violations
### 1.1 Duplicate Validation Schemas
**Location**: `apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts`
```typescript
// ❌ VIOLATION: Duplicate validation schema
const salesforceAccountIdSchema = z.string().min(1, "Salesforce AccountId is required");
```
**Issue**: Creating local Zod schemas instead of using domain validation
**Impact**: Duplication of validation logic, potential inconsistencies
### 1.2 Infrastructure-Specific Types
**Location**: `apps/bff/src/modules/orders/services/order-fulfillment-validator.service.ts`
```typescript
// ❌ VIOLATION: BFF-specific interface that could be domain type
export interface OrderFulfillmentValidationResult {
sfOrder: SalesforceOrderRecord;
clientId: number;
isAlreadyProvisioned: boolean;
whmcsOrderId?: string;
}
```
**Issue**: Business logic types defined in BFF instead of domain
**Impact**: Business logic scattered across layers
### 1.3 Local Type Definitions
**Location**: `apps/bff/src/modules/invoices/services/invoice-retrieval.service.ts`
```typescript
// ❌ VIOLATION: Local interface instead of domain type
interface UserMappingInfo {
userId: string;
whmcsClientId: number;
}
```
**Issue**: Infrastructure types that could be standardized in domain
**Impact**: Inconsistent type definitions across services
### 1.4 Response Schema Definitions
**Location**: `apps/bff/src/modules/orders/orders.controller.ts`
```typescript
// ❌ VIOLATION: Controller-specific response schema
private readonly createOrderResponseSchema = apiSuccessResponseSchema(
z.object({
sfOrderId: z.string(),
status: z.string(),
message: z.string(),
})
);
```
**Issue**: Response schemas defined in controllers instead of domain
**Impact**: API contract not centralized
### 1.5 SOQL Utility Schemas
**Location**: `apps/bff/src/integrations/salesforce/utils/soql.util.ts`
```typescript
// ❌ VIOLATION: Utility-specific schemas
const nonEmptyStringSchema = z.string().min(1, "Value cannot be empty").trim();
const soqlFieldNameSchema = z.string().trim().regex(/^[A-Za-z0-9_.]+$/, "Invalid SOQL field name");
```
**Issue**: Common validation patterns not centralized
**Impact**: Repeated validation logic
## 2. Portal Layer Violations
### 2.1 Component-Specific Types
**Location**: `apps/portal/src/features/catalog/components/base/PricingDisplay.tsx`
```typescript
// ❌ VIOLATION: UI-specific types that could be domain types
export interface PricingTier {
name: string;
price: number;
billingCycle: "Monthly" | "Onetime" | "Annual";
description?: string;
features?: string[];
isRecommended?: boolean;
originalPrice?: number;
}
```
**Issue**: Business concepts defined in UI components
**Impact**: Business logic in presentation layer
### 2.2 Checkout-Specific Types
**Location**: `apps/portal/src/features/checkout/hooks/useCheckout.ts`
```typescript
// ❌ VIOLATION: Business logic types in UI layer
type CheckoutItemType = "plan" | "installation" | "addon" | "activation" | "vpn";
interface CheckoutItem extends CatalogProductBase {
quantity: number;
itemType: CheckoutItemType;
autoAdded?: boolean;
}
interface CheckoutTotals {
monthlyTotal: number;
oneTimeTotal: number;
}
```
**Issue**: Business domain types defined in UI hooks
**Impact**: Business logic scattered across layers
### 2.3 Catalog Utility Types
**Location**: `apps/portal/src/features/catalog/utils/catalog.utils.ts`
```typescript
// ❌ VIOLATION: TODO comment indicates missing domain type
// TODO: Define CatalogFilter type properly
type CatalogFilter = {
category?: string;
priceMin?: number;
priceMax?: number;
search?: string;
};
```
**Issue**: Explicitly acknowledged missing domain type
**Impact**: Business concepts not properly modeled
### 2.4 Form Extension Patterns
**Location**: `apps/portal/src/features/auth/components/SignupForm/SignupForm.tsx`
```typescript
// ⚠️ ACCEPTABLE: Extending domain schema with UI concerns
export const signupFormSchema = signupInputSchema
.extend({
confirmPassword: z.string().min(1, "Please confirm your password"),
})
.refine(data => data.acceptTerms === true, {
message: "You must accept the terms and conditions",
path: ["acceptTerms"],
})
```
**Status**: This is actually a good pattern - extending domain schemas with UI-specific fields
## 3. Cross-Cutting Concerns
### 3.1 Environment Configuration
**Location**: `apps/bff/src/core/config/env.validation.ts`
```typescript
// ⚠️ ACCEPTABLE: Infrastructure configuration
export const envSchema = z.object({
NODE_ENV: z.enum(["development", "test", "production"]).default("development"),
// ... many environment variables
});
```
**Status**: Environment configuration is appropriately infrastructure-specific
### 3.2 Database Mapping Types
**Location**: `apps/bff/src/infra/mappers/mapping.mapper.ts`
```typescript
// ✅ GOOD: Infrastructure mapping with clear documentation
export function mapPrismaMappingToDomain(mapping: PrismaIdMapping): UserIdMapping {
// Maps Prisma entity to Domain type
}
```
**Status**: Proper infrastructure-to-domain mapping
## Recommendations
### 1. Immediate Actions (High Priority)
#### 1.1 Move Business Types to Domain
**Action**: Move the following types to appropriate domain modules:
```typescript
// Move to packages/domain/orders/
export interface OrderFulfillmentValidationResult {
sfOrder: SalesforceOrderRecord;
clientId: number;
isAlreadyProvisioned: boolean;
whmcsOrderId?: string;
}
// Move to packages/domain/catalog/
export interface PricingTier {
name: string;
price: number;
billingCycle: "Monthly" | "Onetime" | "Annual";
description?: string;
features?: string[];
isRecommended?: boolean;
originalPrice?: number;
}
// Move to packages/domain/orders/
export interface CheckoutItem {
// Define based on CatalogProductBase + quantity + itemType
}
```
#### 1.2 Centralize Validation Schemas
**Action**: Move common validation patterns to domain:
```typescript
// Add to packages/domain/common/schema.ts
export const salesforceAccountIdSchema = z.string().min(1, "Salesforce AccountId is required");
export const nonEmptyStringSchema = z.string().min(1, "Value cannot be empty").trim();
export const soqlFieldNameSchema = z.string().trim().regex(/^[A-Za-z0-9_.]+$/, "Invalid SOQL field name");
```
#### 1.3 Define Missing Domain Types
**Action**: Complete the TODO items and define missing types:
```typescript
// Add to packages/domain/catalog/
export interface CatalogFilter {
category?: string;
priceMin?: number;
priceMax?: number;
search?: string;
}
// Add to packages/domain/orders/
export interface OrderCreateResponse {
sfOrderId: string;
status: string;
message: string;
}
```
### 2. Medium Priority Actions
#### 2.1 Standardize Response Types
**Action**: Create standardized API response types in domain:
```typescript
// Add to packages/domain/common/
export interface OrderCreateResponse {
sfOrderId: string;
status: string;
message: string;
}
export const orderCreateResponseSchema = z.object({
sfOrderId: z.string(),
status: z.string(),
message: z.string(),
});
```
#### 2.2 Refactor Checkout Logic
**Action**: Move checkout business logic to domain:
```typescript
// Add to packages/domain/orders/
export interface CheckoutCart {
items: CheckoutItem[];
totals: CheckoutTotals;
configuration: OrderConfigurations;
}
export function calculateCheckoutTotals(items: CheckoutItem[]): CheckoutTotals {
// Move calculation logic from Portal to Domain
}
```
### 3. Long-term Improvements
#### 3.1 Domain Service Layer
**Action**: Consider creating domain services for complex business logic:
```typescript
// Add to packages/domain/orders/services/
export class OrderValidationService {
static validateFulfillmentRequest(
sfOrderId: string,
idempotencyKey: string
): Promise<OrderFulfillmentValidationResult> {
// Move validation logic from BFF to Domain
}
}
```
#### 3.2 Type Safety Improvements
**Action**: Enhance type safety across layers:
```typescript
// Ensure all domain types are properly exported
// Add runtime validation for all domain types
// Implement proper error handling with domain error types
```
## Implementation Plan
### Phase 1: Critical Violations (Week 1-2)
1. Move `OrderFulfillmentValidationResult` to domain
2. Move `PricingTier` to domain
3. Centralize validation schemas
4. Define missing `CatalogFilter` type
### Phase 2: Business Logic Consolidation (Week 3-4)
1. Move checkout types to domain
2. Standardize API response types
3. Refactor validation services
4. Update imports across applications
### Phase 3: Architecture Improvements (Week 5-6)
1. Create domain service layer
2. Implement proper error handling
3. Add comprehensive type exports
4. Update documentation
## Success Metrics
- **Type Reuse**: 90%+ of business types defined in domain
- **Validation Consistency**: Single validation schema per business rule
- **Import Clarity**: Clear domain imports in BFF and Portal
- **Documentation**: Updated architecture documentation
## Conclusion
The analysis reveals that while the domain package is well-structured, there are several violations where business logic and types are scattered across the BFF and Portal layers. The recommended actions will help establish the domain package as the true single source of truth, improving maintainability and consistency across the application.
The violations are primarily in business logic types and validation schemas that should be centralized in the domain layer. The form extension patterns in the Portal are actually good examples of how to properly extend domain schemas with UI-specific concerns.
---
**Report Generated**: $(date)
**Analysis Scope**: BFF and Portal applications
**Domain Package**: `packages/domain/`

View File

@ -9,6 +9,5 @@
* - Domain layer should never import these * - Domain layer should never import these
*/ */
export * from './user.mapper'; export * from "./user.mapper";
export * from './mapping.mapper'; export * from "./mapping.mapper";

View File

@ -23,4 +23,3 @@ export function mapPrismaMappingToDomain(mapping: PrismaIdMapping): UserIdMappin
updatedAt: mapping.updatedAt, updatedAt: mapping.updatedAt,
}; };
} }

View File

@ -41,4 +41,3 @@ export function mapPrismaUserToDomain(user: PrismaUser): UserAuth {
// Use domain provider mapper // Use domain provider mapper
return CustomerProviders.Portal.mapPrismaUserToUserAuth(prismaUserRaw); return CustomerProviders.Portal.mapPrismaUserToUserAuth(prismaUserRaw);
} }

View File

@ -85,8 +85,7 @@ export class FreebitAuthService {
} }
const json: unknown = await response.json(); const json: unknown = await response.json();
const data: FreebitAuthResponse = const data: FreebitAuthResponse = FreebitProvider.mapper.transformFreebitAuthResponse(json);
FreebitProvider.mapper.transformFreebitAuthResponse(json);
if (data.resultCode !== "100" || !data.authKey) { if (data.resultCode !== "100" || !data.authKey) {
throw new FreebitError( throw new FreebitError(

View File

@ -80,10 +80,10 @@ export class FreebitOperationsService {
if (ep !== candidates[0]) { if (ep !== candidates[0]) {
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`); this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
} }
response = await this.client.makeAuthenticatedRequest<FreebitAccountDetailsRaw, typeof request>( response = await this.client.makeAuthenticatedRequest<
ep, FreebitAccountDetailsRaw,
request typeof request
); >(ep, request);
break; break;
} catch (err: unknown) { } catch (err: unknown) {
lastError = err; lastError = err;
@ -116,7 +116,9 @@ export class FreebitOperationsService {
*/ */
async getSimUsage(account: string): Promise<SimUsage> { async getSimUsage(account: string): Promise<SimUsage> {
try { try {
const request: FreebitTrafficInfoRequest = FreebitProvider.schemas.trafficInfo.parse({ account }); const request: FreebitTrafficInfoRequest = FreebitProvider.schemas.trafficInfo.parse({
account,
});
const response = await this.client.makeAuthenticatedRequest< const response = await this.client.makeAuthenticatedRequest<
FreebitTrafficInfoRaw, FreebitTrafficInfoRaw,
@ -143,7 +145,11 @@ export class FreebitOperationsService {
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {} options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
): Promise<void> { ): Promise<void> {
try { try {
const payload: FreebitTopUpRequest = FreebitProvider.schemas.topUp.parse({ account, quotaMb, options }); const payload: FreebitTopUpRequest = FreebitProvider.schemas.topUp.parse({
account,
quotaMb,
options,
});
const quotaKb = Math.round(payload.quotaMb * 1024); const quotaKb = Math.round(payload.quotaMb * 1024);
const baseRequest = { const baseRequest = {
account: payload.account, account: payload.account,
@ -158,10 +164,7 @@ export class FreebitOperationsService {
? { ...baseRequest, runTime: payload.options?.scheduledAt } ? { ...baseRequest, runTime: payload.options?.scheduledAt }
: baseRequest; : baseRequest;
await this.client.makeAuthenticatedRequest<TopUpResponse, typeof request>( await this.client.makeAuthenticatedRequest<TopUpResponse, typeof request>(endpoint, request);
endpoint,
request
);
this.logger.log(`Successfully topped up ${quotaMb}MB for account ${account}`, { this.logger.log(`Successfully topped up ${quotaMb}MB for account ${account}`, {
account, account,
@ -238,17 +241,20 @@ export class FreebitOperationsService {
runTime: parsed.scheduledAt, runTime: parsed.scheduledAt,
}; };
const response = await this.client.makeAuthenticatedRequest<PlanChangeResponse, typeof request>( const response = await this.client.makeAuthenticatedRequest<
"/mvno/changePlan/", PlanChangeResponse,
request typeof request
); >("/mvno/changePlan/", request);
this.logger.log(`Successfully changed plan for account ${parsed.account} to ${parsed.newPlanCode}`, { this.logger.log(
account: parsed.account, `Successfully changed plan for account ${parsed.account} to ${parsed.newPlanCode}`,
newPlanCode: parsed.newPlanCode, {
assignGlobalIp: parsed.assignGlobalIp, account: parsed.account,
scheduled: Boolean(parsed.scheduledAt), newPlanCode: parsed.newPlanCode,
}); assignGlobalIp: parsed.assignGlobalIp,
scheduled: Boolean(parsed.scheduledAt),
}
);
return { return {
ipv4: response.ipv4, ipv4: response.ipv4,
@ -426,17 +432,20 @@ export class FreebitOperationsService {
authKey: await this.auth.getAuthKey(), authKey: await this.auth.getAuthKey(),
}; };
await this.client.makeAuthenticatedRequest<EsimAddAccountResponse, FreebitEsimAddAccountRequest>( await this.client.makeAuthenticatedRequest<
"/mvno/esim/addAcnt/", EsimAddAccountResponse,
payload FreebitEsimAddAccountRequest
); >("/mvno/esim/addAcnt/", payload);
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${parsed.account}`, { this.logger.log(
account: parsed.account, `Successfully reissued eSIM profile via addAcnt for account ${parsed.account}`,
newEid: parsed.newEid, {
oldProductNumber: parsed.oldProductNumber, account: parsed.account,
oldEid: parsed.oldEid, newEid: parsed.newEid,
}); oldProductNumber: parsed.oldProductNumber,
oldEid: parsed.oldEid,
}
);
} catch (error) { } catch (error) {
const message = getErrorMessage(error); const message = getErrorMessage(error);
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, { this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
@ -480,16 +489,17 @@ export class FreebitOperationsService {
} = params; } = params;
// Import schemas dynamically to avoid circular dependencies // Import schemas dynamically to avoid circular dependencies
const validatedParams: FreebitEsimActivationParams = FreebitProvider.schemas.esimActivationParams.parse({ const validatedParams: FreebitEsimActivationParams =
account, FreebitProvider.schemas.esimActivationParams.parse({
eid, account,
planCode, eid,
contractLine, planCode,
aladinOperated, contractLine,
shipDate, aladinOperated,
mnp, shipDate,
identity, mnp,
}); identity,
});
if (!validatedParams.account || !validatedParams.eid) { if (!validatedParams.account || !validatedParams.eid) {
throw new BadRequestException("activateEsimAccountNew requires account and eid"); throw new BadRequestException("activateEsimAccountNew requires account and eid");

View File

@ -5,9 +5,7 @@ import type { SimDetails, SimUsage, SimTopUpHistory } from "@customer-portal/dom
@Injectable() @Injectable()
export class FreebitOrchestratorService { export class FreebitOrchestratorService {
constructor( constructor(private readonly operations: FreebitOperationsService) {}
private readonly operations: FreebitOperationsService
) {}
/** /**
* Get SIM account details * Get SIM account details

View File

@ -55,10 +55,9 @@ export class SalesforceOrderService {
const orderItemProduct2Fields = buildOrderItemProduct2Fields().map( const orderItemProduct2Fields = buildOrderItemProduct2Fields().map(
f => `PricebookEntry.Product2.${f}` f => `PricebookEntry.Product2.${f}`
); );
const orderItemSelect = [ const orderItemSelect = [...buildOrderItemSelectFields(), ...orderItemProduct2Fields].join(
...buildOrderItemSelectFields(), ", "
...orderItemProduct2Fields, );
].join(", ");
const orderSoql = ` const orderSoql = `
SELECT ${orderQueryFields} SELECT ${orderQueryFields}
@ -96,10 +95,7 @@ export class SalesforceOrderService {
); );
// Use domain mapper - single transformation! // Use domain mapper - single transformation!
return OrderProviders.Salesforce.transformSalesforceOrderDetails( return OrderProviders.Salesforce.transformSalesforceOrderDetails(order, orderItems);
order,
orderItems
);
} catch (error: unknown) { } catch (error: unknown) {
this.logger.error("Failed to fetch order with items", { this.logger.error("Failed to fetch order with items", {
error: getErrorMessage(error), error: getErrorMessage(error),
@ -140,10 +136,9 @@ export class SalesforceOrderService {
const orderItemProduct2Fields = buildOrderItemProduct2Fields().map( const orderItemProduct2Fields = buildOrderItemProduct2Fields().map(
f => `PricebookEntry.Product2.${f}` f => `PricebookEntry.Product2.${f}`
); );
const orderItemSelect = [ const orderItemSelect = [...buildOrderItemSelectFields(), ...orderItemProduct2Fields].join(
...buildOrderItemSelectFields(), ", "
...orderItemProduct2Fields, );
].join(", ");
const ordersSoql = ` const ordersSoql = `
SELECT ${orderQueryFields} SELECT ${orderQueryFields}
@ -211,8 +206,8 @@ export class SalesforceOrderService {
// Use domain mapper for each order - single transformation! // Use domain mapper for each order - single transformation!
return orders return orders
.filter((order): order is SalesforceOrderRecord & { Id: string } => .filter(
typeof order.Id === "string" (order): order is SalesforceOrderRecord & { Id: string } => typeof order.Id === "string"
) )
.map(order => .map(order =>
OrderProviders.Salesforce.transformSalesforceOrderSummary( OrderProviders.Salesforce.transformSalesforceOrderSummary(
@ -229,4 +224,3 @@ export class SalesforceOrderService {
} }
} }
} }

View File

@ -17,17 +17,8 @@ export function buildProductQuery(
additionalFields: string[] = [], additionalFields: string[] = [],
additionalConditions: string = "" additionalConditions: string = ""
): string { ): string {
const categoryField = assertSoqlFieldName( const categoryField = assertSoqlFieldName(portalCategoryField, "PRODUCT_PORTAL_CATEGORY_FIELD");
portalCategoryField, const baseFields = ["Id", "Name", "StockKeepingUnit", categoryField, "Item_Class__c"];
"PRODUCT_PORTAL_CATEGORY_FIELD"
);
const baseFields = [
"Id",
"Name",
"StockKeepingUnit",
categoryField,
"Item_Class__c",
];
const allFields = [...baseFields, ...additionalFields].join(", "); const allFields = [...baseFields, ...additionalFields].join(", ");
const safeCategory = sanitizeSoqlLiteral(category); const safeCategory = sanitizeSoqlLiteral(category);

View File

@ -10,9 +10,7 @@ const UNIQUE = <T>(values: T[]): T[] => Array.from(new Set(values));
/** /**
* Build field list for Order queries * Build field list for Order queries
*/ */
export function buildOrderSelectFields( export function buildOrderSelectFields(additional: string[] = []): string[] {
additional: string[] = []
): string[] {
const fields = [ const fields = [
"Id", "Id",
"AccountId", "AccountId",
@ -50,9 +48,7 @@ export function buildOrderSelectFields(
/** /**
* Build field list for OrderItem queries * Build field list for OrderItem queries
*/ */
export function buildOrderItemSelectFields( export function buildOrderItemSelectFields(additional: string[] = []): string[] {
additional: string[] = []
): string[] {
const fields = [ const fields = [
"Id", "Id",
"OrderId", "OrderId",
@ -69,9 +65,7 @@ export function buildOrderItemSelectFields(
/** /**
* Build field list for Product2 fields within OrderItem queries * Build field list for Product2 fields within OrderItem queries
*/ */
export function buildOrderItemProduct2Fields( export function buildOrderItemProduct2Fields(additional: string[] = []): string[] {
additional: string[] = []
): string[] {
const fields = [ const fields = [
"Id", "Id",
"Name", "Name",

View File

@ -25,16 +25,12 @@ import type {
WhmcsValidateLoginResponse, WhmcsValidateLoginResponse,
WhmcsSsoResponse, WhmcsSsoResponse,
} from "@customer-portal/domain/customer"; } from "@customer-portal/domain/customer";
import type { import type { WhmcsProductListResponse } from "@customer-portal/domain/subscriptions";
WhmcsProductListResponse,
} from "@customer-portal/domain/subscriptions";
import type { import type {
WhmcsPaymentMethodListResponse, WhmcsPaymentMethodListResponse,
WhmcsPaymentGatewayListResponse, WhmcsPaymentGatewayListResponse,
} from "@customer-portal/domain/payments"; } from "@customer-portal/domain/payments";
import type { import type { WhmcsCatalogProductListResponse } from "@customer-portal/domain/catalog";
WhmcsCatalogProductListResponse,
} from "@customer-portal/domain/catalog";
import { WhmcsHttpClientService } from "./whmcs-http-client.service"; import { WhmcsHttpClientService } from "./whmcs-http-client.service";
import { WhmcsConfigService } from "../config/whmcs-config.service"; import { WhmcsConfigService } from "../config/whmcs-config.service";
import type { WhmcsRequestOptions } from "../types/connection.types"; import type { WhmcsRequestOptions } from "../types/connection.types";
@ -132,7 +128,9 @@ export class WhmcsApiMethodsService {
// PRODUCT/SUBSCRIPTION API METHODS // PRODUCT/SUBSCRIPTION API METHODS
// ========================================== // ==========================================
async getClientsProducts(params: WhmcsGetClientsProductsParams): Promise<WhmcsProductListResponse> { async getClientsProducts(
params: WhmcsGetClientsProductsParams
): Promise<WhmcsProductListResponse> {
return this.makeRequest("GetClientsProducts", params); return this.makeRequest("GetClientsProducts", params);
} }
@ -144,7 +142,9 @@ export class WhmcsApiMethodsService {
// PAYMENT API METHODS // PAYMENT API METHODS
// ========================================== // ==========================================
async getPaymentMethods(params: WhmcsGetPayMethodsParams): Promise<WhmcsPaymentMethodListResponse> { async getPaymentMethods(
params: WhmcsGetPayMethodsParams
): Promise<WhmcsPaymentMethodListResponse> {
return this.makeRequest("GetPayMethods", params); return this.makeRequest("GetPayMethods", params);
} }

View File

@ -7,8 +7,6 @@ import type {
WhmcsValidateLoginParams, WhmcsValidateLoginParams,
WhmcsAddClientParams, WhmcsAddClientParams,
WhmcsClientResponse, WhmcsClientResponse,
} from "@customer-portal/domain/customer";
import type {
WhmcsAddClientResponse, WhmcsAddClientResponse,
WhmcsValidateLoginResponse, WhmcsValidateLoginResponse,
} from "@customer-portal/domain/customer"; } from "@customer-portal/domain/customer";
@ -38,7 +36,8 @@ export class WhmcsClientService {
password2: password, password2: password,
}; };
const response = await this.connectionService.validateLogin(params); const response: WhmcsValidateLoginResponse =
await this.connectionService.validateLogin(params);
this.logger.log(`Validated login for email: ${email}`); this.logger.log(`Validated login for email: ${email}`);
return { return {
@ -146,7 +145,7 @@ export class WhmcsClientService {
*/ */
async addClient(clientData: WhmcsAddClientParams): Promise<{ clientId: number }> { async addClient(clientData: WhmcsAddClientParams): Promise<{ clientId: number }> {
try { try {
const response = await this.connectionService.addClient(clientData); const response: WhmcsAddClientResponse = await this.connectionService.addClient(clientData);
this.logger.log(`Created new client: ${response.clientid}`); this.logger.log(`Created new client: ${response.clientid}`);
return { clientId: response.clientid }; return { clientId: response.clientid };

View File

@ -94,7 +94,7 @@ export class WhmcsCurrencyService implements OnModuleInit {
try { try {
// The connection service returns the raw WHMCS API response data // The connection service returns the raw WHMCS API response data
// (the WhmcsResponse wrapper is unwrapped by the API methods service) // (the WhmcsResponse wrapper is unwrapped by the API methods service)
const response = await this.connectionService.getCurrencies() as WhmcsCurrenciesResponse; const response = (await this.connectionService.getCurrencies()) as WhmcsCurrenciesResponse;
// Check if response has currencies data (success case) or error fields // Check if response has currencies data (success case) or error fields
if (response.result === "success" || (response.currencies && !response.error)) { if (response.result === "success" || (response.currencies && !response.error)) {
@ -120,7 +120,9 @@ export class WhmcsCurrencyService implements OnModuleInit {
errorcode: response?.errorcode, errorcode: response?.errorcode,
fullResponse: JSON.stringify(response, null, 2), fullResponse: JSON.stringify(response, null, 2),
}); });
throw new Error(`WHMCS GetCurrencies error: ${response?.message || response?.error || "Unknown error"}`); throw new Error(
`WHMCS GetCurrencies error: ${response?.message || response?.error || "Unknown error"}`
);
} }
} catch (error) { } catch (error) {
this.logger.error("Failed to load currencies from WHMCS", { this.logger.error("Failed to load currencies from WHMCS", {
@ -138,7 +140,11 @@ export class WhmcsCurrencyService implements OnModuleInit {
const currencies: WhmcsCurrency[] = []; const currencies: WhmcsCurrency[] = [];
// Check if response has nested currency structure // Check if response has nested currency structure
if (response.currencies && typeof response.currencies === 'object' && 'currency' in response.currencies) { if (
response.currencies &&
typeof response.currencies === "object" &&
"currency" in response.currencies
) {
const currencyArray = Array.isArray(response.currencies.currency) const currencyArray = Array.isArray(response.currencies.currency)
? response.currencies.currency ? response.currencies.currency
: [response.currencies.currency]; : [response.currencies.currency];
@ -146,11 +152,11 @@ export class WhmcsCurrencyService implements OnModuleInit {
for (const currencyData of currencyArray) { for (const currencyData of currencyArray) {
const currency: WhmcsCurrency = { const currency: WhmcsCurrency = {
id: parseInt(String(currencyData.id)) || 0, id: parseInt(String(currencyData.id)) || 0,
code: String(currencyData.code || ''), code: String(currencyData.code || ""),
prefix: String(currencyData.prefix || ''), prefix: String(currencyData.prefix || ""),
suffix: String(currencyData.suffix || ''), suffix: String(currencyData.suffix || ""),
format: String(currencyData.format || '1'), format: String(currencyData.format || "1"),
rate: String(currencyData.rate || '1.00000'), rate: String(currencyData.rate || "1.00000"),
}; };
// Validate that we have essential currency data // Validate that we have essential currency data
@ -160,8 +166,8 @@ export class WhmcsCurrencyService implements OnModuleInit {
} }
} else { } else {
// Fallback: try to parse flat format (currencies[currency][0][id], etc.) // Fallback: try to parse flat format (currencies[currency][0][id], etc.)
const currencyKeys = Object.keys(response).filter(key => const currencyKeys = Object.keys(response).filter(
key.startsWith('currencies[currency][') && key.includes('][id]') key => key.startsWith("currencies[currency][") && key.includes("][id]")
); );
// Extract currency indices // Extract currency indices
@ -176,11 +182,11 @@ export class WhmcsCurrencyService implements OnModuleInit {
for (const index of currencyIndices) { for (const index of currencyIndices) {
const currency: WhmcsCurrency = { const currency: WhmcsCurrency = {
id: parseInt(String(response[`currencies[currency][${index}][id]`])) || 0, id: parseInt(String(response[`currencies[currency][${index}][id]`])) || 0,
code: String(response[`currencies[currency][${index}][code]`] || ''), code: String(response[`currencies[currency][${index}][code]`] || ""),
prefix: String(response[`currencies[currency][${index}][prefix]`] || ''), prefix: String(response[`currencies[currency][${index}][prefix]`] || ""),
suffix: String(response[`currencies[currency][${index}][suffix]`] || ''), suffix: String(response[`currencies[currency][${index}][suffix]`] || ""),
format: String(response[`currencies[currency][${index}][format]`] || '1'), format: String(response[`currencies[currency][${index}][format]`] || "1"),
rate: String(response[`currencies[currency][${index}][rate]`] || '1.00000'), rate: String(response[`currencies[currency][${index}][rate]`] || "1.00000"),
}; };
// Validate that we have essential currency data // Validate that we have essential currency data

View File

@ -1,7 +1,13 @@
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { Injectable, NotFoundException, Inject } from "@nestjs/common"; import { Injectable, NotFoundException, Inject } from "@nestjs/common";
import { Invoice, InvoiceList, invoiceListSchema, invoiceSchema, Providers } from "@customer-portal/domain/billing"; import {
Invoice,
InvoiceList,
invoiceListSchema,
invoiceSchema,
Providers,
} from "@customer-portal/domain/billing";
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service"; import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
import { WhmcsCurrencyService } from "./whmcs-currency.service"; import { WhmcsCurrencyService } from "./whmcs-currency.service";
import { WhmcsCacheService } from "../cache/whmcs-cache.service"; import { WhmcsCacheService } from "../cache/whmcs-cache.service";
@ -65,7 +71,7 @@ export class WhmcsInvoiceService {
...(status && { status: status as WhmcsGetInvoicesParams["status"] }), ...(status && { status: status as WhmcsGetInvoicesParams["status"] }),
}; };
const response = await this.connectionService.getInvoices(params); const response: WhmcsInvoiceListResponse = await this.connectionService.getInvoices(params);
const transformed = this.transformInvoicesResponse(response, clientId, page, limit); const transformed = this.transformInvoicesResponse(response, clientId, page, limit);
const result = invoiceListSchema.parse(transformed as unknown); const result = invoiceListSchema.parse(transformed as unknown);
@ -148,7 +154,7 @@ export class WhmcsInvoiceService {
} }
// Fetch from WHMCS API // Fetch from WHMCS API
const response = await this.connectionService.getInvoice(invoiceId); const response: WhmcsInvoiceResponse = await this.connectionService.getInvoice(invoiceId);
if (!response.invoiceid) { if (!response.invoiceid) {
throw new NotFoundException(`Invoice ${invoiceId} not found`); throw new NotFoundException(`Invoice ${invoiceId} not found`);
@ -291,7 +297,8 @@ export class WhmcsInvoiceService {
itemtaxed1: false, // No tax for data top-ups for now itemtaxed1: false, // No tax for data top-ups for now
}; };
const response = await this.connectionService.createInvoice(whmcsParams); const response: WhmcsCreateInvoiceResponse =
await this.connectionService.createInvoice(whmcsParams);
if (response.result !== "success") { if (response.result !== "success") {
throw new Error(`WHMCS invoice creation failed: ${response.message}`); throw new Error(`WHMCS invoice creation failed: ${response.message}`);
@ -350,7 +357,8 @@ export class WhmcsInvoiceService {
notes: params.notes, notes: params.notes,
}; };
const response = await this.connectionService.updateInvoice(whmcsParams); const response: WhmcsUpdateInvoiceResponse =
await this.connectionService.updateInvoice(whmcsParams);
if (response.result !== "success") { if (response.result !== "success") {
throw new Error(`WHMCS invoice update failed: ${response.message}`); throw new Error(`WHMCS invoice update failed: ${response.message}`);
@ -388,7 +396,8 @@ export class WhmcsInvoiceService {
invoiceid: params.invoiceId, invoiceid: params.invoiceId,
}; };
const response = await this.connectionService.capturePayment(whmcsParams); const response: WhmcsCapturePaymentResponse =
await this.connectionService.capturePayment(whmcsParams);
if (response.result === "success") { if (response.result === "success") {
this.logger.log(`Successfully captured payment for invoice ${params.invoiceId}`, { this.logger.log(`Successfully captured payment for invoice ${params.invoiceId}`, {

View File

@ -3,10 +3,7 @@ import { Logger } from "nestjs-pino";
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service"; import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import type { import type { WhmcsOrderItem, WhmcsAddOrderParams } from "@customer-portal/domain/orders";
WhmcsOrderItem,
WhmcsAddOrderParams,
} from "@customer-portal/domain/orders";
import { Providers } from "@customer-portal/domain/orders"; import { Providers } from "@customer-portal/domain/orders";
export type { WhmcsOrderItem, WhmcsAddOrderParams }; export type { WhmcsOrderItem, WhmcsAddOrderParams };

View File

@ -46,9 +46,11 @@ export class WhmcsPaymentService {
} }
// Fetch pay methods (use the documented WHMCS structure) // Fetch pay methods (use the documented WHMCS structure)
const response: WhmcsPaymentMethodListResponse = await this.connectionService.getPaymentMethods({ const params: WhmcsGetPayMethodsParams = {
clientid: clientId, clientid: clientId,
}); };
const response: WhmcsPaymentMethodListResponse =
await this.connectionService.getPaymentMethods(params);
const paymentMethodsArray: WhmcsPaymentMethod[] = Array.isArray(response.paymethods) const paymentMethodsArray: WhmcsPaymentMethod[] = Array.isArray(response.paymethods)
? response.paymethods ? response.paymethods
@ -117,7 +119,8 @@ export class WhmcsPaymentService {
} }
// Fetch from WHMCS API // Fetch from WHMCS API
const response = await this.connectionService.getPaymentGateways(); const response: WhmcsPaymentGatewayListResponse =
await this.connectionService.getPaymentGateways();
if (!response.gateways?.gateway) { if (!response.gateways?.gateway) {
this.logger.warn("No payment gateways found"); this.logger.warn("No payment gateways found");
@ -129,7 +132,7 @@ export class WhmcsPaymentService {
// Transform payment gateways // Transform payment gateways
const gateways = response.gateways.gateway const gateways = response.gateways.gateway
.map(whmcsGateway => { .map((whmcsGateway: WhmcsPaymentGateway) => {
try { try {
return Providers.Whmcs.transformWhmcsPaymentGateway(whmcsGateway); return Providers.Whmcs.transformWhmcsPaymentGateway(whmcsGateway);
} catch (error) { } catch (error) {

View File

@ -27,7 +27,7 @@ export class WhmcsSsoService {
...(ssoRedirectPath && { sso_redirect_path: ssoRedirectPath }), ...(ssoRedirectPath && { sso_redirect_path: ssoRedirectPath }),
}; };
const response = await this.connectionService.createSsoToken(params); const response: WhmcsSsoResponse = await this.connectionService.createSsoToken(params);
const url = this.resolveRedirectUrl(response.redirect_url); const url = this.resolveRedirectUrl(response.redirect_url);
this.debugLogRedirectHost(url); this.debugLogRedirectHost(url);
@ -83,7 +83,7 @@ export class WhmcsSsoService {
sso_redirect_path: path, sso_redirect_path: path,
}; };
const response = await this.connectionService.createSsoToken(params); const response: WhmcsSsoResponse = await this.connectionService.createSsoToken(params);
// Return the 60s, one-time URL (resolved to absolute) // Return the 60s, one-time URL (resolved to absolute)
const url = this.resolveRedirectUrl(response.redirect_url); const url = this.resolveRedirectUrl(response.redirect_url);
@ -104,7 +104,7 @@ export class WhmcsSsoService {
destination: adminPath || "clientarea.php", destination: adminPath || "clientarea.php",
}; };
const response = await this.connectionService.createSsoToken(params); const response: WhmcsSsoResponse = await this.connectionService.createSsoToken(params);
const url = this.resolveRedirectUrl(response.redirect_url); const url = this.resolveRedirectUrl(response.redirect_url);
this.debugLogRedirectHost(url); this.debugLogRedirectHost(url);
@ -159,7 +159,9 @@ export class WhmcsSsoService {
sso_redirect_path: modulePath, sso_redirect_path: modulePath,
}; };
const response = await this.connectionService.createSsoToken(ssoParams); const response: WhmcsSsoResponse = await this.connectionService.createSsoToken(
ssoParams
);
const url = this.resolveRedirectUrl(response.redirect_url); const url = this.resolveRedirectUrl(response.redirect_url);
this.debugLogRedirectHost(url); this.debugLogRedirectHost(url);

View File

@ -59,9 +59,7 @@ const isRecordOfStrings = (value: unknown): value is Record<string, string> =>
!Array.isArray(value) && !Array.isArray(value) &&
Object.values(value).every(v => typeof v === "string"); Object.values(value).every(v => typeof v === "string");
const normalizeCustomFields = ( const normalizeCustomFields = (raw: RawCustomFields): Record<string, string> | undefined => {
raw: RawCustomFields
): Record<string, string> | undefined => {
if (!raw) return undefined; if (!raw) return undefined;
if (Array.isArray(raw)) { if (Array.isArray(raw)) {
@ -69,10 +67,7 @@ const normalizeCustomFields = (
if (!field) return acc; if (!field) return acc;
const idKey = toOptionalString(field.id)?.trim(); const idKey = toOptionalString(field.id)?.trim();
const nameKey = toOptionalString(field.name)?.trim(); const nameKey = toOptionalString(field.name)?.trim();
const value = const value = field.value === undefined || field.value === null ? "" : String(field.value);
field.value === undefined || field.value === null
? ""
: String(field.value);
if (idKey) acc[idKey] = value; if (idKey) acc[idKey] = value;
if (nameKey) acc[nameKey] = value; if (nameKey) acc[nameKey] = value;
@ -84,15 +79,12 @@ const normalizeCustomFields = (
if (isRecordOfStrings(raw)) { if (isRecordOfStrings(raw)) {
const stringRecord = raw as Record<string, string>; const stringRecord = raw as Record<string, string>;
const map = Object.entries(stringRecord).reduce<Record<string, string>>( const map = Object.entries(stringRecord).reduce<Record<string, string>>((acc, [key, value]) => {
(acc, [key, value]) => { const trimmedKey = key.trim();
const trimmedKey = key.trim(); if (!trimmedKey) return acc;
if (!trimmedKey) return acc; acc[trimmedKey] = value;
acc[trimmedKey] = value; return acc;
return acc; }, {});
},
{}
);
return Object.keys(map).length ? map : undefined; return Object.keys(map).length ? map : undefined;
} }
@ -177,9 +169,7 @@ export interface NormalizedWhmcsClient
raw: WhmcsClient; raw: WhmcsClient;
} }
export const deriveAddressFromClient = ( export const deriveAddressFromClient = (client: WhmcsClient): Address | undefined => {
client: WhmcsClient
): Address | undefined => {
const address = addressSchema.parse({ const address = addressSchema.parse({
address1: client.address1 ?? null, address1: client.address1 ?? null,
address2: client.address2 ?? null, address2: client.address2 ?? null,
@ -200,9 +190,7 @@ export const deriveAddressFromClient = (
return hasValues ? address : undefined; return hasValues ? address : undefined;
}; };
export const normalizeWhmcsClient = ( export const normalizeWhmcsClient = (rawClient: WhmcsClient): NormalizedWhmcsClient => {
rawClient: WhmcsClient
): NormalizedWhmcsClient => {
const id = toNumber(rawClient.id); const id = toNumber(rawClient.id);
if (id === null) { if (id === null) {
throw new Error("WHMCS client ID missing or invalid."); throw new Error("WHMCS client ID missing or invalid.");
@ -222,8 +210,7 @@ export const normalizeWhmcsClient = (
users: normalizeUsers(rawClient.users), users: normalizeUsers(rawClient.users),
allowSingleSignOn: toNullableBoolean(rawClient.allowSingleSignOn) ?? null, allowSingleSignOn: toNullableBoolean(rawClient.allowSingleSignOn) ?? null,
email_verified: toNullableBoolean(rawClient.email_verified) ?? null, email_verified: toNullableBoolean(rawClient.email_verified) ?? null,
marketing_emails_opt_in: marketing_emails_opt_in: toNullableBoolean(rawClient.marketing_emails_opt_in) ?? null,
toNullableBoolean(rawClient.marketing_emails_opt_in) ?? null,
defaultpaymethodid: toNumber(rawClient.defaultpaymethodid), defaultpaymethodid: toNumber(rawClient.defaultpaymethodid),
currency: toNumber(rawClient.currency), currency: toNumber(rawClient.currency),
address: deriveAddressFromClient(rawClient), address: deriveAddressFromClient(rawClient),
@ -254,10 +241,7 @@ export const getCustomFieldValue = (
return undefined; return undefined;
}; };
export const buildUserProfile = ( export const buildUserProfile = (userAuth: UserAuth, client: NormalizedWhmcsClient): User => {
userAuth: UserAuth,
client: NormalizedWhmcsClient
): User => {
const payload = { const payload = {
id: userAuth.id, id: userAuth.id,
email: userAuth.email, email: userAuth.email,
@ -272,10 +256,7 @@ export const buildUserProfile = (
fullname: client.fullname ?? null, fullname: client.fullname ?? null,
companyname: client.companyname ?? null, companyname: client.companyname ?? null,
phonenumber: phonenumber:
client.phonenumberformatted ?? client.phonenumberformatted ?? client.phonenumber ?? client.telephoneNumber ?? null,
client.phonenumber ??
client.telephoneNumber ??
null,
language: client.language ?? null, language: client.language ?? null,
currency_code: client.currency_code ?? null, currency_code: client.currency_code ?? null,
address: client.address ?? undefined, address: client.address ?? undefined,
@ -284,6 +265,4 @@ export const buildUserProfile = (
return userSchema.parse(payload); return userSchema.parse(payload);
}; };
export const getNumericClientId = ( export const getNumericClientId = (client: NormalizedWhmcsClient): number => client.id;
client: NormalizedWhmcsClient
): number => client.id;

View File

@ -1,4 +1,3 @@
import { getErrorMessage } from "@bff/core/utils/error.util";
import { Injectable, Inject } from "@nestjs/common"; import { Injectable, Inject } from "@nestjs/common";
import type { Invoice, InvoiceList } from "@customer-portal/domain/billing"; import type { Invoice, InvoiceList } from "@customer-portal/domain/billing";
import type { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions"; import type { Subscription, SubscriptionList } from "@customer-portal/domain/subscriptions";
@ -14,17 +13,10 @@ import { WhmcsClientService } from "./services/whmcs-client.service";
import { WhmcsPaymentService } from "./services/whmcs-payment.service"; import { WhmcsPaymentService } from "./services/whmcs-payment.service";
import { WhmcsSsoService } from "./services/whmcs-sso.service"; import { WhmcsSsoService } from "./services/whmcs-sso.service";
import { WhmcsOrderService } from "./services/whmcs-order.service"; import { WhmcsOrderService } from "./services/whmcs-order.service";
import type { import type { WhmcsAddClientParams, WhmcsClientResponse } from "@customer-portal/domain/customer";
WhmcsAddClientParams,
WhmcsClientResponse,
} from "@customer-portal/domain/customer";
import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions"; import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions";
import type { import type { WhmcsProductListResponse } from "@customer-portal/domain/subscriptions";
WhmcsProductListResponse, import type { WhmcsCatalogProductListResponse } from "@customer-portal/domain/catalog";
} from "@customer-portal/domain/subscriptions";
import type {
WhmcsCatalogProductListResponse,
} from "@customer-portal/domain/catalog";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { deriveAddressFromClient, type NormalizedWhmcsClient } from "./utils/whmcs-client.utils"; import { deriveAddressFromClient, type NormalizedWhmcsClient } from "./utils/whmcs-client.utils";
@ -282,7 +274,9 @@ export class WhmcsService {
return this.connectionService.getSystemInfo(); return this.connectionService.getSystemInfo();
} }
async getClientsProducts(params: WhmcsGetClientsProductsParams): Promise<WhmcsProductListResponse> { async getClientsProducts(
params: WhmcsGetClientsProductsParams
): Promise<WhmcsProductListResponse> {
return this.connectionService.getClientsProducts(params); return this.connectionService.getClientsProducts(params);
} }

View File

@ -17,14 +17,6 @@ import {
type SetPasswordRequest, type SetPasswordRequest,
type ChangePasswordRequest, type ChangePasswordRequest,
type SsoLinkResponse, type SsoLinkResponse,
type CheckPasswordNeededResponse,
signupRequestSchema,
validateSignupRequestSchema,
linkWhmcsRequestSchema,
setPasswordRequestSchema,
updateProfileRequestSchema,
updateAddressRequestSchema,
changePasswordRequestSchema,
} from "@customer-portal/domain/auth"; } from "@customer-portal/domain/auth";
import type { User as PrismaUser } from "@prisma/client"; import type { User as PrismaUser } from "@prisma/client";
@ -326,10 +318,7 @@ export class AuthFacade {
/** /**
* Create SSO link to WHMCS for general access * Create SSO link to WHMCS for general access
*/ */
async createSsoLink( async createSsoLink(userId: string, destination?: string): Promise<SsoLinkResponse> {
userId: string,
destination?: string
): Promise<SsoLinkResponse> {
try { try {
// Production-safe logging - no sensitive data // Production-safe logging - no sensitive data
this.logger.log("Creating SSO link request"); this.logger.log("Creating SSO link request");

View File

@ -11,12 +11,10 @@ import { getErrorMessage } from "@bff/core/utils/error.util";
import { AuthTokenService } from "../../token/token.service"; import { AuthTokenService } from "../../token/token.service";
import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service"; import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service";
import { import {
type AuthTokens,
type PasswordChangeResult, type PasswordChangeResult,
type ChangePasswordRequest, type ChangePasswordRequest,
changePasswordRequestSchema, changePasswordRequestSchema,
} from "@customer-portal/domain/auth"; } from "@customer-portal/domain/auth";
import type { User } from "@customer-portal/domain/customer";
import { mapPrismaUserToDomain } from "@bff/infra/mappers"; import { mapPrismaUserToDomain } from "@bff/infra/mappers";
@Injectable() @Injectable()

View File

@ -23,9 +23,7 @@ import {
type SignupRequest, type SignupRequest,
type SignupResult, type SignupResult,
type ValidateSignupRequest, type ValidateSignupRequest,
type AuthTokens,
} from "@customer-portal/domain/auth"; } from "@customer-portal/domain/auth";
import type { User } from "@customer-portal/domain/customer";
import { mapPrismaUserToDomain } from "@bff/infra/mappers"; import { mapPrismaUserToDomain } from "@bff/infra/mappers";
import type { User as PrismaUser } from "@prisma/client"; import type { User as PrismaUser } from "@prisma/client";
@ -474,5 +472,4 @@ export class SignupWorkflowService {
result.messages.push("All checks passed. Ready to create your account."); result.messages.push("All checks passed. Ready to create your account.");
return result; return result;
} }
} }

View File

@ -13,7 +13,10 @@ import { SalesforceService } from "@bff/integrations/salesforce/salesforce.servi
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import { mapPrismaUserToDomain } from "@bff/infra/mappers"; import { mapPrismaUserToDomain } from "@bff/infra/mappers";
import type { User } from "@customer-portal/domain/customer"; import type { User } from "@customer-portal/domain/customer";
import { getCustomFieldValue, getNumericClientId } from "@bff/integrations/whmcs/utils/whmcs-client.utils"; import {
getCustomFieldValue,
getNumericClientId,
} from "@bff/integrations/whmcs/utils/whmcs-client.utils";
// No direct Customer import - use inferred type from WHMCS service // No direct Customer import - use inferred type from WHMCS service
@Injectable() @Injectable()

View File

@ -255,7 +255,10 @@ export class AuthController {
@Post("reset-password") @Post("reset-password")
@HttpCode(200) @HttpCode(200)
@UsePipes(new ZodValidationPipe(passwordResetSchema)) @UsePipes(new ZodValidationPipe(passwordResetSchema))
async resetPassword(@Body() body: ResetPasswordRequest, @Res({ passthrough: true }) res: Response) { async resetPassword(
@Body() body: ResetPasswordRequest,
@Res({ passthrough: true }) res: Response
) {
await this.authFacade.resetPassword(body.token, body.password); await this.authFacade.resetPassword(body.token, body.password);
// Clear auth cookies after password reset to force re-login // Clear auth cookies after password reset to force re-login

View File

@ -30,11 +30,9 @@ export class BaseCatalogService {
) { ) {
const portalPricebook = this.configService.get<string>("PORTAL_PRICEBOOK_ID")!; const portalPricebook = this.configService.get<string>("PORTAL_PRICEBOOK_ID")!;
this.portalPriceBookId = assertSalesforceId(portalPricebook, "PORTAL_PRICEBOOK_ID"); this.portalPriceBookId = assertSalesforceId(portalPricebook, "PORTAL_PRICEBOOK_ID");
const portalCategory = this.configService.get<string>("PRODUCT_PORTAL_CATEGORY_FIELD") ?? "Product2Categories1__c"; const portalCategory =
this.portalCategoryField = assertSoqlFieldName( this.configService.get<string>("PRODUCT_PORTAL_CATEGORY_FIELD") ?? "Product2Categories1__c";
portalCategory, this.portalCategoryField = assertSoqlFieldName(portalCategory, "PRODUCT_PORTAL_CATEGORY_FIELD");
"PRODUCT_PORTAL_CATEGORY_FIELD"
);
} }
protected async executeQuery<TRecord extends SalesforceProduct2WithPricebookEntries>( protected async executeQuery<TRecord extends SalesforceProduct2WithPricebookEntries>(

View File

@ -179,5 +179,4 @@ export class InternetCatalogService extends BaseCatalogService {
// e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G" // e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G"
return plan.internetOfferingType === eligibility; return plan.internetOfferingType === eligibility;
} }
} }

View File

@ -3,7 +3,10 @@ import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { BaseCatalogService } from "./base-catalog.service"; import { BaseCatalogService } from "./base-catalog.service";
import type { SalesforceProduct2WithPricebookEntries, VpnCatalogProduct } from "@customer-portal/domain/catalog"; import type {
SalesforceProduct2WithPricebookEntries,
VpnCatalogProduct,
} from "@customer-portal/domain/catalog";
import { Providers as CatalogProviders } from "@customer-portal/domain/catalog"; import { Providers as CatalogProviders } from "@customer-portal/domain/catalog";
@Injectable() @Injectable()
@ -16,10 +19,7 @@ export class VpnCatalogService extends BaseCatalogService {
super(sf, configService, logger); super(sf, configService, logger);
} }
async getPlans(): Promise<VpnCatalogProduct[]> { async getPlans(): Promise<VpnCatalogProduct[]> {
const soql = this.buildCatalogServiceQuery("VPN", [ const soql = this.buildCatalogServiceQuery("VPN", ["VPN_Region__c", "Catalog_Order__c"]);
"VPN_Region__c",
"Catalog_Order__c",
]);
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>( const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
soql, soql,
"VPN Plans" "VPN Plans"

View File

@ -6,6 +6,7 @@ import { WhmcsModule } from "../../integrations/whmcs/whmcs.module";
@Module({ @Module({
imports: [WhmcsModule], imports: [WhmcsModule],
controllers: [CurrencyController], controllers: [CurrencyController],
providers: [], providers: [WhmcsCurrencyService],
exports: [WhmcsCurrencyService],
}) })
export class CurrencyModule {} export class CurrencyModule {}

View File

@ -53,7 +53,6 @@ export class MappingsService {
throw error; throw error;
} }
const warnings = checkMappingCompleteness(validatedRequest);
const sanitizedRequest = validatedRequest; const sanitizedRequest = validatedRequest;
const [byUser, byWhmcs, bySf] = await Promise.all([ const [byUser, byWhmcs, bySf] = await Promise.all([
@ -68,18 +67,15 @@ export class MappingsService {
: Promise.resolve(null), : Promise.resolve(null),
]); ]);
if (byUser) { const existingMappings = [byUser, byWhmcs, bySf]
throw new ConflictException(`User ${sanitizedRequest.userId} already has a mapping`); .filter((mapping): mapping is Prisma.IdMapping => mapping !== null)
} .map(mapPrismaMappingToDomain);
if (byWhmcs) {
throw new ConflictException( const conflictCheck = validateNoConflicts(sanitizedRequest, existingMappings);
`WHMCS client ${sanitizedRequest.whmcsClientId} is already mapped to user ${byWhmcs.userId}` const warnings = [...checkMappingCompleteness(sanitizedRequest), ...conflictCheck.warnings];
);
} if (!conflictCheck.isValid) {
if (bySf) { throw new ConflictException(conflictCheck.errors.join("; "));
this.logger.warn(
`Salesforce account ${sanitizedRequest.sfAccountId} is already mapped to user ${bySf.userId}`
);
} }
let created; let created;

View File

@ -9,7 +9,4 @@ export * from "./services/invoice-retrieval.service";
export * from "./services/invoice-health.service"; export * from "./services/invoice-health.service";
// Export monitoring types (infrastructure concerns) // Export monitoring types (infrastructure concerns)
export type { export type { InvoiceHealthStatus, InvoiceServiceStats } from "./types/invoice-monitoring.types";
InvoiceHealthStatus,
InvoiceServiceStats,
} from "./types/invoice-monitoring.types";

View File

@ -16,10 +16,19 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
import { ZodValidationPipe } from "@bff/core/validation"; import { ZodValidationPipe } from "@bff/core/validation";
import type { RequestWithUser } from "@bff/modules/auth/auth.types"; import type { RequestWithUser } from "@bff/modules/auth/auth.types";
import type { Invoice, InvoiceList, InvoiceSsoLink, InvoiceListQuery } from "@customer-portal/domain/billing"; import type {
Invoice,
InvoiceList,
InvoiceSsoLink,
InvoiceListQuery,
} from "@customer-portal/domain/billing";
import { invoiceListQuerySchema, invoiceSchema } from "@customer-portal/domain/billing"; import { invoiceListQuerySchema, invoiceSchema } from "@customer-portal/domain/billing";
import type { Subscription } from "@customer-portal/domain/subscriptions"; import type { Subscription } from "@customer-portal/domain/subscriptions";
import type { PaymentMethodList, PaymentGatewayList, InvoicePaymentLink } from "@customer-portal/domain/payments"; import type {
PaymentMethodList,
PaymentGatewayList,
InvoicePaymentLink,
} from "@customer-portal/domain/payments";
/** /**
* Invoice Controller * Invoice Controller

View File

@ -16,11 +16,7 @@ import { InvoiceHealthService } from "./services/invoice-health.service";
@Module({ @Module({
imports: [WhmcsModule, MappingsModule], imports: [WhmcsModule, MappingsModule],
controllers: [InvoicesController], controllers: [InvoicesController],
providers: [ providers: [InvoicesOrchestratorService, InvoiceRetrievalService, InvoiceHealthService],
InvoicesOrchestratorService,
InvoiceRetrievalService,
InvoiceHealthService,
],
exports: [InvoicesOrchestratorService], exports: [InvoicesOrchestratorService],
}) })
export class InvoicesModule {} export class InvoicesModule {}

View File

@ -146,21 +146,30 @@ export class InvoiceRetrievalService {
/** /**
* Get unpaid invoices for a user * Get unpaid invoices for a user
*/ */
async getUnpaidInvoices(userId: string, options: Partial<InvoiceListQuery> = {}): Promise<InvoiceList> { async getUnpaidInvoices(
userId: string,
options: Partial<InvoiceListQuery> = {}
): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Unpaid", options); return this.getInvoicesByStatus(userId, "Unpaid", options);
} }
/** /**
* Get overdue invoices for a user * Get overdue invoices for a user
*/ */
async getOverdueInvoices(userId: string, options: Partial<InvoiceListQuery> = {}): Promise<InvoiceList> { async getOverdueInvoices(
userId: string,
options: Partial<InvoiceListQuery> = {}
): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Overdue", options); return this.getInvoicesByStatus(userId, "Overdue", options);
} }
/** /**
* Get paid invoices for a user * Get paid invoices for a user
*/ */
async getPaidInvoices(userId: string, options: Partial<InvoiceListQuery> = {}): Promise<InvoiceList> { async getPaidInvoices(
userId: string,
options: Partial<InvoiceListQuery> = {}
): Promise<InvoiceList> {
return this.getInvoicesByStatus(userId, "Paid", options); return this.getInvoicesByStatus(userId, "Paid", options);
} }

View File

@ -10,10 +10,7 @@ import {
} from "@customer-portal/domain/billing"; } from "@customer-portal/domain/billing";
import { InvoiceRetrievalService } from "./invoice-retrieval.service"; import { InvoiceRetrievalService } from "./invoice-retrieval.service";
import { InvoiceHealthService } from "./invoice-health.service"; import { InvoiceHealthService } from "./invoice-health.service";
import type { import type { InvoiceHealthStatus, InvoiceServiceStats } from "../types/invoice-monitoring.types";
InvoiceHealthStatus,
InvoiceServiceStats,
} from "../types/invoice-monitoring.types";
/** /**
* Main orchestrator service for invoice operations * Main orchestrator service for invoice operations
@ -86,21 +83,30 @@ export class InvoicesOrchestratorService {
/** /**
* Get unpaid invoices for a user * Get unpaid invoices for a user
*/ */
async getUnpaidInvoices(userId: string, options: Partial<InvoiceListQuery> = {}): Promise<InvoiceList> { async getUnpaidInvoices(
userId: string,
options: Partial<InvoiceListQuery> = {}
): Promise<InvoiceList> {
return this.retrievalService.getUnpaidInvoices(userId, options); return this.retrievalService.getUnpaidInvoices(userId, options);
} }
/** /**
* Get overdue invoices for a user * Get overdue invoices for a user
*/ */
async getOverdueInvoices(userId: string, options: Partial<InvoiceListQuery> = {}): Promise<InvoiceList> { async getOverdueInvoices(
userId: string,
options: Partial<InvoiceListQuery> = {}
): Promise<InvoiceList> {
return this.retrievalService.getOverdueInvoices(userId, options); return this.retrievalService.getOverdueInvoices(userId, options);
} }
/** /**
* Get paid invoices for a user * Get paid invoices for a user
*/ */
async getPaidInvoices(userId: string, options: Partial<InvoiceListQuery> = {}): Promise<InvoiceList> { async getPaidInvoices(
userId: string,
options: Partial<InvoiceListQuery> = {}
): Promise<InvoiceList> {
return this.retrievalService.getPaidInvoices(userId, options); return this.retrievalService.getPaidInvoices(userId, options);
} }

View File

@ -24,4 +24,3 @@ export interface InvoiceHealthStatus {
timestamp: string; timestamp: string;
}; };
} }

View File

@ -74,10 +74,7 @@ export class OrderBuilder {
assignIfString(orderFields, "Access_Mode__c", config.accessMode); assignIfString(orderFields, "Access_Mode__c", config.accessMode);
} }
private addSimFields( private addSimFields(orderFields: Record<string, unknown>, body: OrderBusinessValidation): void {
orderFields: Record<string, unknown>,
body: OrderBusinessValidation
): void {
const config = body.configurations || {}; const config = body.configurations || {};
assignIfString(orderFields, "SIM_Type__c", config.simType); assignIfString(orderFields, "SIM_Type__c", config.simType);
assignIfString(orderFields, "EID__c", config.eid); assignIfString(orderFields, "EID__c", config.eid);
@ -91,7 +88,11 @@ export class OrderBuilder {
assignIfString(orderFields, "Porting_Last_Name__c", config.portingLastName); assignIfString(orderFields, "Porting_Last_Name__c", config.portingLastName);
assignIfString(orderFields, "Porting_First_Name__c", config.portingFirstName); assignIfString(orderFields, "Porting_First_Name__c", config.portingFirstName);
assignIfString(orderFields, "Porting_Last_Name_Katakana__c", config.portingLastNameKatakana); assignIfString(orderFields, "Porting_Last_Name_Katakana__c", config.portingLastNameKatakana);
assignIfString(orderFields, "Porting_First_Name_Katakana__c", config.portingFirstNameKatakana); assignIfString(
orderFields,
"Porting_First_Name_Katakana__c",
config.portingFirstNameKatakana
);
assignIfString(orderFields, "Porting_Gender__c", config.portingGender); assignIfString(orderFields, "Porting_Gender__c", config.portingGender);
assignIfString(orderFields, "Porting_Date_Of_Birth__c", config.portingDateOfBirth); assignIfString(orderFields, "Porting_Date_Of_Birth__c", config.portingDateOfBirth);
} }
@ -124,9 +125,12 @@ export class OrderBuilder {
orderFields.Billing_Street__c = fullStreet; orderFields.Billing_Street__c = fullStreet;
orderFields.Billing_City__c = typeof addressToUse?.city === "string" ? addressToUse.city : ""; orderFields.Billing_City__c = typeof addressToUse?.city === "string" ? addressToUse.city : "";
orderFields.Billing_State__c = typeof addressToUse?.state === "string" ? addressToUse.state : ""; orderFields.Billing_State__c =
orderFields.Billing_Postal_Code__c = typeof addressToUse?.postcode === "string" ? addressToUse.postcode : ""; typeof addressToUse?.state === "string" ? addressToUse.state : "";
orderFields.Billing_Country__c = typeof addressToUse?.country === "string" ? addressToUse.country : ""; orderFields.Billing_Postal_Code__c =
typeof addressToUse?.postcode === "string" ? addressToUse.postcode : "";
orderFields.Billing_Country__c =
typeof addressToUse?.country === "string" ? addressToUse.country : "";
orderFields.Address_Changed__c = addressChanged; orderFields.Address_Changed__c = addressChanged;
if (addressChanged) { if (addressChanged) {

View File

@ -14,13 +14,7 @@ import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service"
import { SimFulfillmentService } from "./sim-fulfillment.service"; import { SimFulfillmentService } from "./sim-fulfillment.service";
import { DistributedTransactionService } from "@bff/core/database/services/distributed-transaction.service"; import { DistributedTransactionService } from "@bff/core/database/services/distributed-transaction.service";
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import { import { type OrderDetails, Providers as OrderProviders } from "@customer-portal/domain/orders";
type OrderSummary,
type OrderDetails,
type SalesforceOrderRecord,
type SalesforceOrderItemRecord,
Providers as OrderProviders,
} from "@customer-portal/domain/orders";
type WhmcsOrderItemMappingResult = ReturnType<typeof OrderProviders.Whmcs.mapOrderToWhmcsItems>; type WhmcsOrderItemMappingResult = ReturnType<typeof OrderProviders.Whmcs.mapOrderToWhmcsItems>;
@ -138,14 +132,12 @@ export class OrderFulfillmentOrchestrator {
id: "sf_status_update", id: "sf_status_update",
description: "Update Salesforce order status to Activating", description: "Update Salesforce order status to Activating",
execute: async () => { execute: async () => {
return await this.salesforceService.updateOrder({ return await this.salesforceService.updateOrder({
Id: sfOrderId, Id: sfOrderId,
Activation_Status__c: "Activating", Activation_Status__c: "Activating",
}); });
}, },
rollback: async () => { rollback: async () => {
await this.salesforceService.updateOrder({ await this.salesforceService.updateOrder({
Id: sfOrderId, Id: sfOrderId,
Activation_Status__c: "Failed", Activation_Status__c: "Failed",
@ -281,8 +273,6 @@ export class OrderFulfillmentOrchestrator {
id: "sf_success_update", id: "sf_success_update",
description: "Update Salesforce with success", description: "Update Salesforce with success",
execute: async () => { execute: async () => {
return await this.salesforceService.updateOrder({ return await this.salesforceService.updateOrder({
Id: sfOrderId, Id: sfOrderId,
Status: "Completed", Status: "Completed",
@ -291,7 +281,6 @@ export class OrderFulfillmentOrchestrator {
}); });
}, },
rollback: async () => { rollback: async () => {
await this.salesforceService.updateOrder({ await this.salesforceService.updateOrder({
Id: sfOrderId, Id: sfOrderId,
Activation_Status__c: "Failed", Activation_Status__c: "Failed",
@ -330,7 +319,6 @@ export class OrderFulfillmentOrchestrator {
return context; return context;
} }
/** /**
* Initialize fulfillment steps * Initialize fulfillment steps
*/ */
@ -353,7 +341,6 @@ export class OrderFulfillmentOrchestrator {
return steps; return steps;
} }
private extractConfigurations(value: unknown): Record<string, unknown> { private extractConfigurations(value: unknown): Record<string, unknown> {
if (value && typeof value === "object") { if (value && typeof value === "object") {
return value as Record<string, unknown>; return value as Record<string, unknown>;
@ -361,7 +348,6 @@ export class OrderFulfillmentOrchestrator {
return {}; return {};
} }
/** /**
* Handle fulfillment errors and update Salesforce * Handle fulfillment errors and update Salesforce
*/ */
@ -372,7 +358,6 @@ export class OrderFulfillmentOrchestrator {
const errorCode = this.orderFulfillmentErrorService.determineErrorCode(error); const errorCode = this.orderFulfillmentErrorService.determineErrorCode(error);
const userMessage = error.message; const userMessage = error.message;
this.logger.error("Fulfillment orchestration failed", { this.logger.error("Fulfillment orchestration failed", {
sfOrderId: context.sfOrderId, sfOrderId: context.sfOrderId,
idempotencyKey: context.idempotencyKey, idempotencyKey: context.idempotencyKey,

View File

@ -1,4 +1,4 @@
import { Injectable, BadRequestException, ConflictException, Inject } from "@nestjs/common"; import { Injectable, BadRequestException, Inject } from "@nestjs/common";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { z } from "zod"; import { z } from "zod";
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service"; import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
@ -7,7 +7,6 @@ import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SalesforceOrderRecord } from "@customer-portal/domain/orders"; import type { SalesforceOrderRecord } from "@customer-portal/domain/orders";
import { sfOrderIdParamSchema } from "@customer-portal/domain/orders"; import { sfOrderIdParamSchema } from "@customer-portal/domain/orders";
import { PaymentValidatorService } from "./payment-validator.service"; import { PaymentValidatorService } from "./payment-validator.service";
type OrderStringFieldKey = "activationStatus";
// Schema for validating Salesforce Account ID // Schema for validating Salesforce Account ID
const salesforceAccountIdSchema = z.string().min(1, "Salesforce AccountId is required"); const salesforceAccountIdSchema = z.string().min(1, "Salesforce AccountId is required");
@ -115,7 +114,6 @@ export class OrderFulfillmentValidator {
throw new BadRequestException(`Cannot provision cancelled order ${sfOrderId}`); throw new BadRequestException(`Cannot provision cancelled order ${sfOrderId}`);
} }
this.logger.log("Salesforce order validated", { this.logger.log("Salesforce order validated", {
sfOrderId, sfOrderId,
status: order.Status, status: order.Status,

View File

@ -4,10 +4,7 @@ import { SalesforceOrderService } from "@bff/integrations/salesforce/services/sa
import { OrderValidator } from "./order-validator.service"; import { OrderValidator } from "./order-validator.service";
import { OrderBuilder } from "./order-builder.service"; import { OrderBuilder } from "./order-builder.service";
import { OrderItemBuilder } from "./order-item-builder.service"; import { OrderItemBuilder } from "./order-item-builder.service";
import { import { type OrderDetails, type OrderSummary } from "@customer-portal/domain/orders";
type OrderDetails,
type OrderSummary,
} from "@customer-portal/domain/orders";
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util"; import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util";
type OrderDetailsResponse = OrderDetails; type OrderDetailsResponse = OrderDetails;

View File

@ -2,7 +2,6 @@ import { Inject, Injectable, NotFoundException } from "@nestjs/common";
import { ConfigService } from "@nestjs/config"; import { ConfigService } from "@nestjs/config";
import { Logger } from "nestjs-pino"; import { Logger } from "nestjs-pino";
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service"; import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
import { z } from "zod";
import type { import type {
SalesforceProduct2Record, SalesforceProduct2Record,
SalesforcePricebookEntryRecord, SalesforcePricebookEntryRecord,
@ -78,7 +77,6 @@ export class OrderPricebookService {
return new Map(); return new Map();
} }
const meta = new Map<string, PricebookProductMeta>(); const meta = new Map<string, PricebookProductMeta>();
for (let i = 0; i < uniqueSkus.length; i += this.chunkSize) { for (let i = 0; i < uniqueSkus.length; i += this.chunkSize) {

View File

@ -23,10 +23,7 @@ export class PaymentValidatorService {
* @param whmcsClientId - WHMCS client ID to check * @param whmcsClientId - WHMCS client ID to check
* @throws BadRequestException if no payment method exists or verification fails * @throws BadRequestException if no payment method exists or verification fails
*/ */
async validatePaymentMethodExists( async validatePaymentMethodExists(userId: string, whmcsClientId: number): Promise<void> {
userId: string,
whmcsClientId: number
): Promise<void> {
try { try {
const pay = await this.whmcs.getPaymentMethods({ clientid: whmcsClientId }); const pay = await this.whmcs.getPaymentMethods({ clientid: whmcsClientId });
const paymentMethods = Array.isArray(pay.paymethods) ? pay.paymethods : []; const paymentMethods = Array.isArray(pay.paymethods) ? pay.paymethods : [];
@ -36,7 +33,10 @@ export class PaymentValidatorService {
throw new BadRequestException("A payment method is required before ordering"); throw new BadRequestException("A payment method is required before ordering");
} }
this.logger.log({ userId, whmcsClientId, count: paymentMethods.length }, "Payment method verified"); this.logger.log(
{ userId, whmcsClientId, count: paymentMethods.length },
"Payment method verified"
);
} catch (e: unknown) { } catch (e: unknown) {
// Re-throw BadRequestException as-is // Re-throw BadRequestException as-is
if (e instanceof BadRequestException) { if (e instanceof BadRequestException) {
@ -49,4 +49,3 @@ export class PaymentValidatorService {
} }
} }
} }

View File

@ -120,10 +120,7 @@ export class SimManagementService {
/** /**
* Get comprehensive SIM information (details + usage combined) * Get comprehensive SIM information (details + usage combined)
*/ */
async getSimInfo( async getSimInfo(userId: string, subscriptionId: number): Promise<SimInfo> {
userId: string,
subscriptionId: number
): Promise<SimInfo> {
return this.simOrchestrator.getSimInfo(userId, subscriptionId); return this.simOrchestrator.getSimInfo(userId, subscriptionId);
} }

View File

@ -111,10 +111,7 @@ export class SimOrchestratorService {
/** /**
* Get comprehensive SIM information (details + usage combined) * Get comprehensive SIM information (details + usage combined)
*/ */
async getSimInfo( async getSimInfo(userId: string, subscriptionId: number): Promise<SimInfo> {
userId: string,
subscriptionId: number
): Promise<SimInfo> {
try { try {
const [details, usage] = await Promise.all([ const [details, usage] = await Promise.all([
this.getSimDetails(userId, subscriptionId), this.getSimDetails(userId, subscriptionId),

View File

@ -3,7 +3,11 @@ import { Logger } from "nestjs-pino";
import { SubscriptionsService } from "../../subscriptions.service"; import { SubscriptionsService } from "../../subscriptions.service";
import { getErrorMessage } from "@bff/core/utils/error.util"; import { getErrorMessage } from "@bff/core/utils/error.util";
import type { SimValidationResult } from "../interfaces/sim-base.interface"; import type { SimValidationResult } from "../interfaces/sim-base.interface";
import { cleanSimAccount, extractSimAccountFromSubscription, isSimSubscription } from "@customer-portal/domain/sim"; import {
cleanSimAccount,
extractSimAccountFromSubscription,
isSimSubscription,
} from "@customer-portal/domain/sim";
@Injectable() @Injectable()
export class SimValidationService { export class SimValidationService {

View File

@ -139,7 +139,10 @@ export class SubscriptionsService {
/** /**
* Get subscriptions by status * Get subscriptions by status
*/ */
async getSubscriptionsByStatus(userId: string, status: SubscriptionStatus): Promise<Subscription[]> { async getSubscriptionsByStatus(
userId: string,
status: SubscriptionStatus
): Promise<Subscription[]> {
try { try {
const normalizedStatus = subscriptionStatusSchema.parse(status); const normalizedStatus = subscriptionStatusSchema.parse(status);
const subscriptionList = await this.getSubscriptions(userId, { status: normalizedStatus }); const subscriptionList = await this.getSubscriptions(userId, { status: normalizedStatus });

View File

@ -71,7 +71,10 @@ export class UsersController {
*/ */
@Patch() @Patch()
@UsePipes(new ZodValidationPipe(updateCustomerProfileRequestSchema)) @UsePipes(new ZodValidationPipe(updateCustomerProfileRequestSchema))
async updateProfile(@Req() req: RequestWithUser, @Body() updateData: UpdateCustomerProfileRequest) { async updateProfile(
@Req() req: RequestWithUser,
@Body() updateData: UpdateCustomerProfileRequest
) {
return this.usersService.updateProfile(req.user.id, updateData); return this.usersService.updateProfile(req.user.id, updateData);
} }
} }

View File

@ -25,13 +25,7 @@ import { buildUserProfile } from "@bff/integrations/whmcs/utils/whmcs-client.uti
// Use a subset of PrismaUser for auth-related updates only // Use a subset of PrismaUser for auth-related updates only
type UserUpdateData = Partial< type UserUpdateData = Partial<
Pick< Pick<PrismaUser, "passwordHash" | "failedLoginAttempts" | "lastLoginAt" | "lockedUntil">
PrismaUser,
| "passwordHash"
| "failedLoginAttempts"
| "lastLoginAt"
| "lockedUntil"
>
>; >;
@Injectable() @Injectable()
@ -281,7 +275,10 @@ export class UsersService {
return this.getProfile(validId); return this.getProfile(validId);
} catch (error) { } catch (error) {
const msg = getErrorMessage(error); const msg = getErrorMessage(error);
this.logger.error({ userId: validId, error: msg }, "Failed to update customer profile in WHMCS"); this.logger.error(
{ userId: validId, error: msg },
"Failed to update customer profile in WHMCS"
);
if (msg.includes("WHMCS API Error")) { if (msg.includes("WHMCS API Error")) {
throw new BadRequestException(msg.replace("WHMCS API Error: ", "")); throw new BadRequestException(msg.replace("WHMCS API Error: ", ""));
@ -505,7 +502,8 @@ export class UsersService {
productName: subscription.productName, productName: subscription.productName,
status: subscription.status, status: subscription.status,
} as Record<string, unknown>; } as Record<string, unknown>;
if (subscription.registrationDate) metadata.registrationDate = subscription.registrationDate; if (subscription.registrationDate)
metadata.registrationDate = subscription.registrationDate;
activities.push({ activities.push({
id: `service-activated-${subscription.id}`, id: `service-activated-${subscription.id}`,
type: "service_activated", type: "service_activated",
@ -561,5 +559,4 @@ export class UsersService {
throw new BadRequestException("Unable to retrieve dashboard summary"); throw new BadRequestException("Unable to retrieve dashboard summary");
} }
} }
} }

View File

@ -124,9 +124,7 @@ const nextConfig = {
}; };
const preferredExtensions = [".ts", ".tsx", ".mts", ".cts"]; const preferredExtensions = [".ts", ".tsx", ".mts", ".cts"];
const existingExtensions = config.resolve.extensions || []; const existingExtensions = config.resolve.extensions || [];
config.resolve.extensions = [ config.resolve.extensions = [...new Set([...preferredExtensions, ...existingExtensions])];
...new Set([...preferredExtensions, ...existingExtensions]),
];
config.resolve.extensionAlias = { config.resolve.extensionAlias = {
...(config.resolve.extensionAlias || {}), ...(config.resolve.extensionAlias || {}),
".js": [".ts", ".tsx", ".js"], ".js": [".ts", ".tsx", ".js"],

View File

@ -68,6 +68,7 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
if (props.as === "a") { if (props.as === "a") {
const { className, variant, size, as: _as, href, ...anchorProps } = rest as ButtonAsAnchorProps; const { className, variant, size, as: _as, href, ...anchorProps } = rest as ButtonAsAnchorProps;
void _as;
return ( return (
<a <a
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}
@ -97,6 +98,7 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
disabled, disabled,
...buttonProps ...buttonProps
} = rest as ButtonAsButtonProps; } = rest as ButtonAsButtonProps;
void _as;
return ( return (
<button <button
className={cn(buttonVariants({ variant, size, className }))} className={cn(buttonVariants({ variant, size, className }))}

View File

@ -23,8 +23,9 @@ export function AsyncBlock({
if (isLoading) { if (isLoading) {
if (variant === "page") { if (variant === "page") {
return ( return (
<div className="py-8"> <div className="py-8" aria-busy="true" aria-live="polite">
<div className="max-w-5xl mx-auto px-4 sm:px-6 md:px-8 space-y-6"> <div className="max-w-5xl mx-auto px-4 sm:px-6 md:px-8 space-y-6">
{loadingText ? <p className="text-sm text-gray-500">{loadingText}</p> : null}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<Skeleton className="h-8 w-8 rounded-full" /> <Skeleton className="h-8 w-8 rounded-full" />
<div className="space-y-2"> <div className="space-y-2">
@ -45,7 +46,8 @@ export function AsyncBlock({
); );
} }
return ( return (
<div className="space-y-3"> <div className="space-y-3" aria-busy="true" aria-live="polite">
{loadingText ? <p className="text-sm text-gray-500">{loadingText}</p> : null}
<Skeleton className="h-4 w-40" /> <Skeleton className="h-4 w-40" />
<Skeleton className="h-3 w-3/5" /> <Skeleton className="h-3 w-3/5" />
<Skeleton className="h-3 w-2/5" /> <Skeleton className="h-3 w-2/5" />

View File

@ -41,6 +41,8 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
const errorId = error ? `${id}-error` : undefined; const errorId = error ? `${id}-error` : undefined;
const helperTextId = helperText ? `${id}-helper` : undefined; const helperTextId = helperText ? `${id}-helper` : undefined;
const { className: inputPropsClassName, ...restInputProps } = inputProps;
return ( return (
<div className={cn("space-y-1", containerClassName)}> <div className={cn("space-y-1", containerClassName)}>
{label && ( {label && (
@ -80,12 +82,9 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
className={cn( className={cn(
error && "border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2", error && "border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2",
inputClassName, inputClassName,
inputProps.className inputPropsClassName
)} )}
{...(() => { {...restInputProps}
const { className, ...rest } = inputProps;
return rest;
})()}
/> />
)} )}
{error && <ErrorMessage id={errorId}>{error}</ErrorMessage>} {error && <ErrorMessage id={errorId}>{error}</ErrorMessage>}

View File

@ -26,7 +26,10 @@ export function AppShell({ children }: AppShellProps) {
const pathname = usePathname(); const pathname = usePathname();
const router = useRouter(); const router = useRouter();
const activeSubscriptionsQuery = useActiveSubscriptions(); const activeSubscriptionsQuery = useActiveSubscriptions();
const activeSubscriptions: Subscription[] = activeSubscriptionsQuery.data ?? []; const activeSubscriptions: Subscription[] = useMemo(
() => activeSubscriptionsQuery.data ?? [],
[activeSubscriptionsQuery.data]
);
// Initialize with a stable default to avoid hydration mismatch // Initialize with a stable default to avoid hydration mismatch
const [expandedItems, setExpandedItems] = useState<string[]>([]); const [expandedItems, setExpandedItems] = useState<string[]>([]);
@ -88,7 +91,7 @@ export function AppShell({ children }: AppShellProps) {
// best-effort profile hydration; ignore errors // best-effort profile hydration; ignore errors
} }
})(); })();
}, [hasCheckedAuth, isAuthenticated, user?.firstname, user?.lastname]); }, [hasCheckedAuth, hydrateUserProfile, isAuthenticated, user?.firstname, user?.lastname]);
// Auto-expand sections when browsing their routes // Auto-expand sections when browsing their routes
useEffect(() => { useEffect(() => {

View File

@ -1,5 +1,4 @@
import type { Subscription } from "@customer-portal/domain/subscriptions"; import type { Subscription } from "@customer-portal/domain/subscriptions";
import type { ReactNode } from "react";
import { import {
HomeIcon, HomeIcon,
CreditCardIcon, CreditCardIcon,

View File

@ -27,8 +27,9 @@ export function AddressCard({
onSave, onSave,
onAddressChange, onAddressChange,
}: AddressCardProps) { }: AddressCardProps) {
const countryLabel = const countryLabel = address.country
address.country ? getCountryName(address.country) ?? address.country : null; ? (getCountryName(address.country) ?? address.country)
: null;
return ( return (
<SubCard> <SubCard>

View File

@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { LoadingCard, Skeleton } from "@/components/atoms/loading-skeleton"; import { Skeleton } from "@/components/atoms/loading-skeleton";
import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner"; import { AlertBanner } from "@/components/molecules/AlertBanner/AlertBanner";
import { import {
MapPinIcon, MapPinIcon,
@ -83,7 +83,7 @@ export default function ProfileContainer() {
setLoading(false); setLoading(false);
} }
})(); })();
}, [user?.id]); }, [address, profile, user?.id]);
if (loading) { if (loading) {
return ( return (
@ -237,7 +237,9 @@ export default function ProfileContainer() {
/> />
) : ( ) : (
<p className="text-sm text-gray-900 py-2"> <p className="text-sm text-gray-900 py-2">
{user?.phonenumber || <span className="text-gray-500 italic">Not provided</span>} {user?.phonenumber || (
<span className="text-gray-500 italic">Not provided</span>
)}
</p> </p>
)} )}
</div> </div>

View File

@ -45,6 +45,7 @@ export function LoginForm({
const handleLogin = useCallback( const handleLogin = useCallback(
async ({ rememberMe: _rememberMe, ...formData }: LoginFormValues) => { async ({ rememberMe: _rememberMe, ...formData }: LoginFormValues) => {
void _rememberMe;
clearError(); clearError();
try { try {
// formData already matches LoginRequest schema (email, password) // formData already matches LoginRequest schema (email, password)

View File

@ -11,10 +11,7 @@ import { Button, Input, ErrorMessage } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField"; import { FormField } from "@/components/molecules/FormField/FormField";
import { usePasswordReset } from "../../hooks/use-auth"; import { usePasswordReset } from "../../hooks/use-auth";
import { useZodForm } from "@customer-portal/validation"; import { useZodForm } from "@customer-portal/validation";
import { import { passwordResetRequestSchema, passwordResetSchema } from "@customer-portal/domain/auth";
passwordResetRequestSchema,
passwordResetSchema,
} from "@customer-portal/domain/auth";
import { z } from "zod"; import { z } from "zod";
interface PasswordResetFormProps { interface PasswordResetFormProps {
@ -80,6 +77,7 @@ export function PasswordResetForm({
schema: resetFormSchema, schema: resetFormSchema,
initialValues: { token: token || "", password: "", confirmPassword: "" }, initialValues: { token: token || "", password: "", confirmPassword: "" },
onSubmit: async ({ confirmPassword: _ignore, ...data }) => { onSubmit: async ({ confirmPassword: _ignore, ...data }) => {
void _ignore;
try { try {
await resetPassword(data.token, data.password); await resetPassword(data.token, data.password);
onSuccess?.(); onSuccess?.();
@ -92,8 +90,6 @@ export function PasswordResetForm({
}); });
// Get the current form based on mode // Get the current form based on mode
const currentForm = mode === "request" ? requestForm : resetForm;
// Handle errors from auth hooks // Handle errors from auth hooks
useEffect(() => { useEffect(() => {
if (error) { if (error) {
@ -106,7 +102,7 @@ export function PasswordResetForm({
clearError(); clearError();
requestForm.reset(); requestForm.reset();
resetForm.reset(); resetForm.reset();
}, [mode, clearError]); }, [mode, clearError, requestForm, resetForm]);
if (mode === "request") { if (mode === "request") {
return ( return (

View File

@ -61,6 +61,8 @@ export function SetPasswordForm({
confirmPassword: "", confirmPassword: "",
}, },
onSubmit: async ({ confirmPassword: _ignore, ...data }) => { onSubmit: async ({ confirmPassword: _ignore, ...data }) => {
void _ignore;
clearError();
try { try {
await setPassword(data.email, data.password); await setPassword(data.email, data.password);
onSuccess?.(); onSuccess?.();
@ -84,7 +86,7 @@ export function SetPasswordForm({
if (email && email !== form.values.email) { if (email && email !== form.values.email) {
form.setValue("email", email); form.setValue("email", email);
} }
}, [email, form.values.email]); }, [email, form]);
return ( return (
<div className={`space-y-6 ${className}`}> <div className={`space-y-6 ${className}`}>

View File

@ -5,7 +5,6 @@
"use client"; "use client";
import { Input } from "@/components/atoms";
import { FormField } from "@/components/molecules/FormField/FormField"; import { FormField } from "@/components/molecules/FormField/FormField";
interface AccountStepProps { interface AccountStepProps {

View File

@ -36,7 +36,7 @@ export const signupFormSchema = signupInputSchema
.extend({ .extend({
confirmPassword: z.string().min(1, "Please confirm your password"), confirmPassword: z.string().min(1, "Please confirm your password"),
}) })
.refine((data) => data.acceptTerms === true, { .refine(data => data.acceptTerms === true, {
message: "You must accept the terms and conditions", message: "You must accept the terms and conditions",
path: ["acceptTerms"], path: ["acceptTerms"],
}) })
@ -63,6 +63,7 @@ export function SignupForm({
const handleSignup = useCallback( const handleSignup = useCallback(
async ({ confirmPassword: _confirm, ...formData }: SignupFormValues) => { async ({ confirmPassword: _confirm, ...formData }: SignupFormValues) => {
void _confirm;
clearError(); clearError();
try { try {
const normalizeCountryCode = (value?: string) => { const normalizeCountryCode = (value?: string) => {
@ -73,8 +74,7 @@ export function SignupForm({
const normalizedAddress = formData.address const normalizedAddress = formData.address
? (() => { ? (() => {
const countryValue = const countryValue = formData.address.country || formData.address.countryCode || "";
formData.address.country || formData.address.countryCode || "";
const normalizedCountry = normalizeCountryCode(countryValue); const normalizedCountry = normalizeCountryCode(countryValue);
return { return {
...formData.address, ...formData.address,

View File

@ -158,7 +158,7 @@ export const useAuthStore = create<AuthState>()((set, get) => {
} }
}, },
changePassword: async (currentPassword: string, newPassword: string) => { changePassword: async (currentPassword: string, newPassword: string) => {
set({ loading: true, error: null }); set({ loading: true, error: null });
try { try {
const response = await apiClient.POST("/api/auth/change-password", { const response = await apiClient.POST("/api/auth/change-password", {

View File

@ -10,7 +10,6 @@ import {
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { StatusPill } from "@/components/atoms/status-pill"; import { StatusPill } from "@/components/atoms/status-pill";
import type { StatusPillProps } from "@/components/atoms/status-pill"; import type { StatusPillProps } from "@/components/atoms/status-pill";
import type { InvoiceStatus } from "@customer-portal/domain/billing";
interface BillingStatusBadgeProps extends Omit<StatusPillProps, "variant" | "icon" | "label"> { interface BillingStatusBadgeProps extends Omit<StatusPillProps, "variant" | "icon" | "label"> {
status: string; status: string;

View File

@ -9,11 +9,10 @@ import {
ClockIcon, ClockIcon,
ArrowRightIcon, ArrowRightIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { BillingStatusBadge } from "../BillingStatusBadge";
import type { BillingSummary } from "@customer-portal/domain/billing"; import type { BillingSummary } from "@customer-portal/domain/billing";
import { Formatting } from "@customer-portal/domain/toolkit"; import { Formatting } from "@customer-portal/domain/toolkit";
const { formatCurrency, getCurrencyLocale } = Formatting; const { formatCurrency } = Formatting;
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
interface BillingSummaryProps extends React.HTMLAttributes<HTMLDivElement> { interface BillingSummaryProps extends React.HTMLAttributes<HTMLDivElement> {
@ -46,8 +45,7 @@ const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
); );
} }
const formatAmount = (amount: number) => const formatAmount = (amount: number) => formatCurrency(amount, summary.currency);
formatCurrency(amount, summary.currency);
const summaryItems = [ const summaryItems = [
{ {

View File

@ -2,11 +2,7 @@
import React from "react"; import React from "react";
import { Skeleton } from "@/components/atoms/loading-skeleton"; import { Skeleton } from "@/components/atoms/loading-skeleton";
import { import { ArrowTopRightOnSquareIcon, ArrowDownTrayIcon } from "@heroicons/react/24/outline";
ArrowTopRightOnSquareIcon,
ArrowDownTrayIcon,
ServerIcon,
} from "@heroicons/react/24/outline";
import { format } from "date-fns"; import { format } from "date-fns";
import type { Invoice } from "@customer-portal/domain/billing"; import type { Invoice } from "@customer-portal/domain/billing";
import { Formatting } from "@customer-portal/domain/toolkit"; import { Formatting } from "@customer-portal/domain/toolkit";
@ -29,22 +25,12 @@ interface InvoiceHeaderProps {
invoice: Invoice; invoice: Invoice;
loadingDownload?: boolean; loadingDownload?: boolean;
loadingPayment?: boolean; loadingPayment?: boolean;
loadingPaymentMethods?: boolean;
onDownload?: () => void; onDownload?: () => void;
onPay?: () => void; onPay?: () => void;
onManagePaymentMethods?: () => void;
} }
export function InvoiceHeader(props: InvoiceHeaderProps) { export function InvoiceHeader(props: InvoiceHeaderProps) {
const { const { invoice, loadingDownload, loadingPayment, onDownload, onPay } = props;
invoice,
loadingDownload,
loadingPayment,
loadingPaymentMethods,
onDownload,
onPay,
onManagePaymentMethods,
} = props;
return ( return (
<div className="relative bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6"> <div className="relative bg-gradient-to-r from-slate-900 via-slate-800 to-slate-900 px-8 py-6">
@ -56,7 +42,6 @@ export function InvoiceHeader(props: InvoiceHeaderProps) {
<div className="relative"> <div className="relative">
{/* Structured Header Layout */} {/* Structured Header Layout */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-center"> <div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-center">
{/* Left Section - Invoice Info */} {/* Left Section - Invoice Info */}
<div className="lg:col-span-1"> <div className="lg:col-span-1">
<div className="space-y-2"> <div className="space-y-2">

View File

@ -27,23 +27,35 @@ export function InvoiceItems({ items = [], currency }: InvoiceItemsProps) {
const itemContent = ( const itemContent = (
<div <div
className={`flex justify-between items-start py-4 rounded-lg transition-all duration-200 ${ className={`flex justify-between items-start py-4 rounded-lg transition-all duration-200 ${
index !== items.length - 1 ? 'border-b border-slate-100' : '' index !== items.length - 1 ? "border-b border-slate-100" : ""
} ${ } ${
isLinked isLinked
? 'hover:bg-blue-50 hover:border-blue-200 cursor-pointer group' ? "hover:bg-blue-50 hover:border-blue-200 cursor-pointer group"
: 'bg-slate-50/50' : "bg-slate-50/50"
}`} }`}
> >
<div className="flex-1 pr-4"> <div className="flex-1 pr-4">
<div className="flex items-start gap-3"> <div className="flex items-start gap-3">
<div className="flex-1"> <div className="flex-1">
<div className={`font-semibold mb-1 ${ <div
isLinked ? 'text-blue-900 group-hover:text-blue-700' : 'text-slate-900' className={`font-semibold mb-1 ${
}`}> isLinked ? "text-blue-900 group-hover:text-blue-700" : "text-slate-900"
}`}
>
{item.description} {item.description}
{isLinked && ( {isLinked && (
<svg className="inline-block w-4 h-4 ml-1 text-blue-500 group-hover:text-blue-600" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14" /> className="inline-block w-4 h-4 ml-1 text-blue-500 group-hover:text-blue-600"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg> </svg>
)} )}
</div> </div>
@ -56,14 +68,22 @@ export function InvoiceItems({ items = [], currency }: InvoiceItemsProps) {
{isLinked ? ( {isLinked ? (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800"> <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800">
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z" clipRule="evenodd" /> <path
fillRule="evenodd"
d="M12.586 4.586a2 2 0 112.828 2.828l-3 3a2 2 0 01-2.828 0 1 1 0 00-1.414 1.414 4 4 0 005.656 0l3-3a4 4 0 00-5.656-5.656l-1.5 1.5a1 1 0 101.414 1.414l1.5-1.5zm-5 5a2 2 0 012.828 0 1 1 0 101.414-1.414 4 4 0 00-5.656 0l-3 3a4 4 0 105.656 5.656l1.5-1.5a1 1 0 10-1.414-1.414l-1.5 1.5a2 2 0 11-2.828-2.828l3-3z"
clipRule="evenodd"
/>
</svg> </svg>
Service #{item.serviceId} Service #{item.serviceId}
</span> </span>
) : ( ) : (
<span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-600"> <span className="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-slate-100 text-slate-600">
<svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20"> <svg className="w-3 h-3 mr-1" fill="currentColor" viewBox="0 0 20 20">
<path fillRule="evenodd" d="M4 4a2 2 0 00-2 2v8a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2H4zm0 2v8h12V6H4z" clipRule="evenodd" /> <path
fillRule="evenodd"
d="M4 4a2 2 0 00-2 2v8a2 2 0 002 2h12a2 2 0 002-2V6a2 2 0 00-2-2H4zm0 2v8h12V6H4z"
clipRule="evenodd"
/>
</svg> </svg>
One-time item One-time item
</span> </span>
@ -73,9 +93,11 @@ export function InvoiceItems({ items = [], currency }: InvoiceItemsProps) {
</div> </div>
</div> </div>
<div className="text-right"> <div className="text-right">
<div className={`text-xl font-bold ${ <div
isLinked ? 'text-blue-900 group-hover:text-blue-700' : 'text-slate-900' className={`text-xl font-bold ${
}`}> isLinked ? "text-blue-900 group-hover:text-blue-700" : "text-slate-900"
}`}
>
{formatCurrency(item.amount || 0, currency)} {formatCurrency(item.amount || 0, currency)}
</div> </div>
</div> </div>
@ -84,21 +106,13 @@ export function InvoiceItems({ items = [], currency }: InvoiceItemsProps) {
if (isLinked) { if (isLinked) {
return ( return (
<Link <Link key={item.id} href={`/subscriptions/${item.serviceId}`} className="block">
key={item.id}
href={`/subscriptions/${item.serviceId}`}
className="block"
>
{itemContent} {itemContent}
</Link> </Link>
); );
} }
return ( return <div key={item.id}>{itemContent}</div>;
<div key={item.id}>
{itemContent}
</div>
);
}; };
return ( return (
@ -130,8 +144,18 @@ export function InvoiceItems({ items = [], currency }: InvoiceItemsProps) {
) : ( ) : (
<div className="text-center py-8"> <div className="text-center py-8">
<div className="text-slate-400 mb-2"> <div className="text-slate-400 mb-2">
<svg className="w-12 h-12 mx-auto" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" /> className="w-12 h-12 mx-auto"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={1.5}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg> </svg>
</div> </div>
<p className="text-slate-500">No items found on this invoice.</p> <p className="text-slate-500">No items found on this invoice.</p>

View File

@ -3,7 +3,7 @@ import { format, formatDistanceToNowStrict } from "date-fns";
import { ArrowDownTrayIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline"; import { ArrowDownTrayIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline";
import { Formatting } from "@customer-portal/domain/toolkit"; import { Formatting } from "@customer-portal/domain/toolkit";
const { formatCurrency, getCurrencyLocale } = Formatting; const { formatCurrency } = Formatting;
import type { Invoice } from "@customer-portal/domain/billing"; import type { Invoice } from "@customer-portal/domain/billing";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { StatusPill } from "@/components/atoms/status-pill"; import { StatusPill } from "@/components/atoms/status-pill";
@ -17,7 +17,9 @@ interface InvoiceSummaryBarProps {
onPay?: () => void; onPay?: () => void;
} }
const statusVariantMap: Partial<Record<Invoice["status"], "success" | "warning" | "error" | "neutral">> = { const statusVariantMap: Partial<
Record<Invoice["status"], "success" | "warning" | "error" | "neutral">
> = {
Paid: "success", Paid: "success",
Unpaid: "warning", Unpaid: "warning",
Overdue: "error", Overdue: "error",
@ -46,12 +48,16 @@ function formatDisplayDate(dateString?: string) {
return format(date, "dd MMM yyyy"); return format(date, "dd MMM yyyy");
} }
function formatRelativeDue(dateString: string | undefined, status: Invoice["status"], daysOverdue?: number) { function formatRelativeDue(
dateString: string | undefined,
status: Invoice["status"],
daysOverdue?: number
) {
if (!dateString) return null; if (!dateString) return null;
if (status === "Paid") return null; if (status === "Paid") return null;
if (status === "Overdue" && daysOverdue) { if (status === "Overdue" && daysOverdue) {
return `${daysOverdue} day${daysOverdue !== 1 ? 's' : ''} overdue`; return `${daysOverdue} day${daysOverdue !== 1 ? "s" : ""} overdue`;
} else if (status === "Unpaid") { } else if (status === "Unpaid") {
const dueDate = new Date(dateString); const dueDate = new Date(dateString);
if (Number.isNaN(dueDate.getTime())) return null; if (Number.isNaN(dueDate.getTime())) return null;
@ -70,8 +76,7 @@ export function InvoiceSummaryBar({
onPay, onPay,
}: InvoiceSummaryBarProps) { }: InvoiceSummaryBarProps) {
const formattedTotal = useMemo( const formattedTotal = useMemo(
() => () => formatCurrency(invoice.total, invoice.currency),
formatCurrency(invoice.total, invoice.currency),
[invoice.currency, invoice.total] [invoice.currency, invoice.total]
); );
@ -96,7 +101,6 @@ export function InvoiceSummaryBar({
<div className="max-w-4xl mx-auto"> <div className="max-w-4xl mx-auto">
{/* Header layout with proper alignment */} {/* Header layout with proper alignment */}
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6"> <div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
{/* Left section: Amount, currency, and status */} {/* Left section: Amount, currency, and status */}
<div className="flex-1"> <div className="flex-1">
<div className="flex items-baseline gap-4 mb-3"> <div className="flex items-baseline gap-4 mb-3">
@ -121,10 +125,12 @@ export function InvoiceSummaryBar({
{relativeDue && ( {relativeDue && (
<> <>
{dueDisplay && <span className="text-slate-400"></span>} {dueDisplay && <span className="text-slate-400"></span>}
<span className={cn( <span
"font-medium", className={cn(
invoice.status === "Overdue" ? "text-red-600" : "text-amber-600" "font-medium",
)}> invoice.status === "Overdue" ? "text-red-600" : "text-amber-600"
)}
>
{relativeDue} {relativeDue}
</span> </span>
</> </>
@ -163,15 +169,11 @@ export function InvoiceSummaryBar({
{/* Invoice metadata - inline layout */} {/* Invoice metadata - inline layout */}
<div className="flex flex-col sm:flex-row lg:flex-col xl:flex-row gap-2 lg:items-end text-sm text-slate-600"> <div className="flex flex-col sm:flex-row lg:flex-col xl:flex-row gap-2 lg:items-end text-sm text-slate-600">
<div className="font-semibold text-slate-900"> <div className="font-semibold text-slate-900">Invoice #{invoice.number}</div>
Invoice #{invoice.number}
</div>
{issuedDisplay && ( {issuedDisplay && (
<> <>
<span className="hidden sm:inline lg:hidden xl:inline text-slate-400"></span> <span className="hidden sm:inline lg:hidden xl:inline text-slate-400"></span>
<div> <div>Issued {issuedDisplay}</div>
Issued {issuedDisplay}
</div>
</> </>
)} )}
</div> </div>

View File

@ -55,7 +55,8 @@ export function InvoicesList({
error: unknown; error: unknown;
}; };
const invoices = data?.invoices || []; const rawInvoices = data?.invoices;
const invoices = useMemo(() => rawInvoices ?? [], [rawInvoices]);
const pagination = data?.pagination; const pagination = data?.pagination;
const filtered = useMemo(() => { const filtered = useMemo(() => {
@ -95,9 +96,7 @@ export function InvoicesList({
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4"> <div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
{/* Title Section */} {/* Title Section */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<h2 className="text-lg font-semibold text-gray-900"> <h2 className="text-lg font-semibold text-gray-900">Invoices</h2>
Invoices
</h2>
{pagination?.totalItems && ( {pagination?.totalItems && (
<span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700"> <span className="inline-flex items-center px-2.5 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700">
{pagination.totalItems} total {pagination.totalItems} total

View File

@ -5,21 +5,18 @@ import { useRouter } from "next/navigation";
import { format } from "date-fns"; import { format } from "date-fns";
import { import {
DocumentTextIcon, DocumentTextIcon,
ArrowTopRightOnSquareIcon,
CheckCircleIcon, CheckCircleIcon,
ExclamationTriangleIcon, ExclamationTriangleIcon,
ClockIcon, ClockIcon,
CreditCardIcon,
ArrowDownTrayIcon, ArrowDownTrayIcon,
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { CheckCircleIcon as CheckCircleIconSolid } from "@heroicons/react/24/solid";
import { DataTable } from "@/components/molecules/DataTable/DataTable"; import { DataTable } from "@/components/molecules/DataTable/DataTable";
import { Button } from "@/components/atoms/button"; import { Button } from "@/components/atoms/button";
import { BillingStatusBadge } from "../BillingStatusBadge"; import { BillingStatusBadge } from "../BillingStatusBadge";
import type { Invoice } from "@customer-portal/domain/billing"; import type { Invoice } from "@customer-portal/domain/billing";
import { Formatting } from "@customer-portal/domain/toolkit"; import { Formatting } from "@customer-portal/domain/toolkit";
const { formatCurrency, getCurrencyLocale } = Formatting; const { formatCurrency } = Formatting;
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling"; import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
import { openSsoLink } from "@/features/billing/utils/sso"; import { openSsoLink } from "@/features/billing/utils/sso";
@ -77,7 +74,7 @@ export function InvoiceTable({
try { try {
const ssoLink = await createSsoLinkMutation.mutateAsync({ const ssoLink = await createSsoLinkMutation.mutateAsync({
invoiceId: invoice.id, invoiceId: invoice.id,
target: "pay" target: "pay",
}); });
openSsoLink(ssoLink.url, { newTab: true }); openSsoLink(ssoLink.url, { newTab: true });
} catch (err) { } catch (err) {
@ -93,7 +90,7 @@ export function InvoiceTable({
try { try {
const ssoLink = await createSsoLinkMutation.mutateAsync({ const ssoLink = await createSsoLinkMutation.mutateAsync({
invoiceId: invoice.id, invoiceId: invoice.id,
target: "download" target: "download",
}); });
openSsoLink(ssoLink.url, { newTab: false }); openSsoLink(ssoLink.url, { newTab: false });
} catch (err) { } catch (err) {
@ -113,13 +110,9 @@ export function InvoiceTable({
const statusIcon = getStatusIcon(invoice.status); const statusIcon = getStatusIcon(invoice.status);
return ( return (
<div className="flex items-start space-x-3 py-3"> <div className="flex items-start space-x-3 py-3">
<div className="flex-shrink-0 mt-1"> <div className="flex-shrink-0 mt-1">{statusIcon}</div>
{statusIcon}
</div>
<div className="min-w-0 flex-1"> <div className="min-w-0 flex-1">
<div className="font-semibold text-gray-900 text-sm"> <div className="font-semibold text-gray-900 text-sm">{invoice.number}</div>
{invoice.number}
</div>
{!compact && invoice.description && ( {!compact && invoice.description && (
<div className="text-sm text-gray-600 mt-1.5 line-clamp-2 leading-relaxed"> <div className="text-sm text-gray-600 mt-1.5 line-clamp-2 leading-relaxed">
{invoice.description} {invoice.description}
@ -164,7 +157,7 @@ export function InvoiceTable({
</span> </span>
{invoice.daysOverdue && ( {invoice.daysOverdue && (
<div className="text-xs text-red-700 font-medium"> <div className="text-xs text-red-700 font-medium">
{invoice.daysOverdue} day{invoice.daysOverdue !== 1 ? 's' : ''} overdue {invoice.daysOverdue} day{invoice.daysOverdue !== 1 ? "s" : ""} overdue
</div> </div>
)} )}
</div> </div>
@ -190,11 +183,7 @@ export function InvoiceTable({
} }
}; };
return ( return <div className="py-3">{renderStatusWithDate()}</div>;
<div className="py-3">
{renderStatusWithDate()}
</div>
);
}, },
}, },
{ {
@ -247,7 +236,9 @@ export function InvoiceTable({
void handleDownload(invoice, event); void handleDownload(invoice, event);
}} }}
loading={isDownloadLoading} loading={isDownloadLoading}
leftIcon={!isDownloadLoading ? <ArrowDownTrayIcon className="h-4 w-4" /> : undefined} leftIcon={
!isDownloadLoading ? <ArrowDownTrayIcon className="h-4 w-4" /> : undefined
}
className="text-xs font-medium border-gray-300 hover:border-gray-400 hover:bg-gray-50" className="text-xs font-medium border-gray-300 hover:border-gray-400 hover:bg-gray-50"
title="Download PDF" title="Download PDF"
> >

View File

@ -1,6 +1,11 @@
"use client"; "use client";
import { CreditCardIcon, BanknotesIcon, DevicePhoneMobileIcon, CheckCircleIcon } from "@heroicons/react/24/outline"; import {
CreditCardIcon,
BanknotesIcon,
DevicePhoneMobileIcon,
CheckCircleIcon,
} from "@heroicons/react/24/outline";
import type { PaymentMethod } from "@customer-portal/domain/payments"; import type { PaymentMethod } from "@customer-portal/domain/payments";
import { cn } from "@/lib/utils"; import { cn } from "@/lib/utils";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
@ -16,8 +21,10 @@ const getBrandColor = (brand?: string) => {
const brandLower = brand?.toLowerCase() || ""; const brandLower = brand?.toLowerCase() || "";
if (brandLower.includes("visa")) return "from-blue-600 to-blue-700"; if (brandLower.includes("visa")) return "from-blue-600 to-blue-700";
if (brandLower.includes("mastercard") || brandLower.includes("master")) return "from-red-500 to-red-600"; if (brandLower.includes("mastercard") || brandLower.includes("master"))
if (brandLower.includes("amex") || brandLower.includes("american")) return "from-gray-700 to-gray-800"; return "from-red-500 to-red-600";
if (brandLower.includes("amex") || brandLower.includes("american"))
return "from-gray-700 to-gray-800";
if (brandLower.includes("discover")) return "from-orange-500 to-orange-600"; if (brandLower.includes("discover")) return "from-orange-500 to-orange-600";
if (brandLower.includes("mobile")) return "from-purple-500 to-purple-600"; if (brandLower.includes("mobile")) return "from-purple-500 to-purple-600";
@ -25,7 +32,8 @@ const getBrandColor = (brand?: string) => {
}; };
const getMethodIcon = (type: PaymentMethod["type"], brand?: string) => { const getMethodIcon = (type: PaymentMethod["type"], brand?: string) => {
const baseClasses = "w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center shadow-sm"; const baseClasses =
"w-12 h-12 bg-gradient-to-br rounded-xl flex items-center justify-center shadow-sm";
if (isBankAccount(type)) { if (isBankAccount(type)) {
return ( return (
@ -36,7 +44,9 @@ const getMethodIcon = (type: PaymentMethod["type"], brand?: string) => {
} }
const brandColor = getBrandColor(brand); const brandColor = getBrandColor(brand);
const IconComponent = brand?.toLowerCase().includes("mobile") ? DevicePhoneMobileIcon : CreditCardIcon; const IconComponent = brand?.toLowerCase().includes("mobile")
? DevicePhoneMobileIcon
: CreditCardIcon;
return ( return (
<div className={`${baseClasses} ${brandColor}`}> <div className={`${baseClasses} ${brandColor}`}>
@ -118,9 +128,7 @@ export function PaymentMethodCard({
</div> </div>
<div className="flex items-center gap-4 text-sm"> <div className="flex items-center gap-4 text-sm">
{cardBrand && ( {cardBrand && <span className="text-gray-600 font-medium">{cardBrand}</span>}
<span className="text-gray-600 font-medium">{cardBrand}</span>
)}
{expiry && ( {expiry && (
<> <>
{cardBrand && <span className="text-gray-300"></span>} {cardBrand && <span className="text-gray-300"></span>}
@ -137,9 +145,7 @@ export function PaymentMethodCard({
</div> </div>
</div> </div>
{showActions && actionSlot && ( {showActions && actionSlot && <div className="flex-shrink-0 ml-4">{actionSlot}</div>}
<div className="flex-shrink-0 ml-4">{actionSlot}</div>
)}
</div> </div>
); );
} }

View File

@ -66,8 +66,16 @@ const PaymentMethodCard = forwardRef<HTMLDivElement, PaymentMethodCardProps>(
}, },
ref ref
) => { ) => {
const { type, description, gatewayName, isDefault, expiryDate, bankName, cardType, cardLastFour } = const {
paymentMethod; type,
description,
gatewayName,
isDefault,
expiryDate,
bankName,
cardType,
cardLastFour,
} = paymentMethod;
const formatExpiryDate = (expiry?: string) => { const formatExpiryDate = (expiry?: string) => {
if (!expiry) return null; if (!expiry) return null;

View File

@ -10,7 +10,11 @@ import { useAuthStore } from "@/features/auth/services/auth.store";
import { isApiError } from "@/lib/api"; import { isApiError } from "@/lib/api";
import { openSsoLink } from "@/features/billing/utils/sso"; import { openSsoLink } from "@/features/billing/utils/sso";
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh"; import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
import { PaymentMethodCard, usePaymentMethods, useCreatePaymentMethodsSsoLink } from "@/features/billing"; import {
PaymentMethodCard,
usePaymentMethods,
useCreatePaymentMethodsSsoLink,
} from "@/features/billing";
import { CreditCardIcon, PlusIcon } from "@heroicons/react/24/outline"; import { CreditCardIcon, PlusIcon } from "@heroicons/react/24/outline";
import { InlineToast } from "@/components/atoms/inline-toast"; import { InlineToast } from "@/components/atoms/inline-toast";
import { SectionHeader } from "@/components/molecules"; import { SectionHeader } from "@/components/molecules";
@ -42,8 +46,7 @@ export function PaymentMethodsContainer() {
const result = await paymentMethodsQuery.refetch(); const result = await paymentMethodsQuery.refetch();
return { data: result.data }; return { data: result.data };
}, },
hasMethods: data => hasMethods: data => Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)),
Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)),
attachFocusListeners: true, attachFocusListeners: true,
}); });
@ -61,7 +64,14 @@ export function PaymentMethodsContainer() {
} catch (err: unknown) { } catch (err: unknown) {
logger.error(err, "Failed to open payment methods"); logger.error(err, "Failed to open payment methods");
// Check if error looks like an API error with response // Check if error looks like an API error with response
if (isApiError(err) && 'response' in err && typeof err.response === 'object' && err.response !== null && 'status' in err.response && err.response.status === 401) { if (
isApiError(err) &&
"response" in err &&
typeof err.response === "object" &&
err.response !== null &&
"status" in err.response &&
err.response.status === 401
) {
setError("Authentication failed. Please log in again."); setError("Authentication failed. Please log in again.");
} else { } else {
setError("Unable to access payment methods. Please try again later."); setError("Unable to access payment methods. Please try again later.");
@ -145,7 +155,8 @@ export function PaymentMethodsContainer() {
<div> <div>
<h2 className="text-xl font-semibold text-gray-900">Your Payment Methods</h2> <h2 className="text-xl font-semibold text-gray-900">Your Payment Methods</h2>
<p className="text-sm text-gray-600 mt-1"> <p className="text-sm text-gray-600 mt-1">
{paymentMethodsData.paymentMethods.length} payment method{paymentMethodsData.paymentMethods.length !== 1 ? 's' : ''} on file {paymentMethodsData.paymentMethods.length} payment method
{paymentMethodsData.paymentMethods.length !== 1 ? "s" : ""} on file
</p> </p>
</div> </div>
<div className="text-right"> <div className="text-right">
@ -159,16 +170,14 @@ export function PaymentMethodsContainer() {
> >
{createPaymentMethodsSsoLink.isPending ? "Opening..." : "Manage Cards"} {createPaymentMethodsSsoLink.isPending ? "Opening..." : "Manage Cards"}
</Button> </Button>
<p className="text-xs text-gray-500 mt-1"> <p className="text-xs text-gray-500 mt-1">Opens in a new tab for security</p>
Opens in a new tab for security
</p>
</div> </div>
</div> </div>
</div> </div>
<div className="p-6"> <div className="p-6">
<div className="space-y-4"> <div className="space-y-4">
{paymentMethodsData.paymentMethods.map((paymentMethod) => ( {paymentMethodsData.paymentMethods.map(paymentMethod => (
<PaymentMethodCard <PaymentMethodCard
key={paymentMethod.id} key={paymentMethod.id}
paymentMethod={paymentMethod} paymentMethod={paymentMethod}
@ -204,9 +213,7 @@ export function PaymentMethodsContainer() {
> >
{createPaymentMethodsSsoLink.isPending ? "Opening..." : "Manage Cards"} {createPaymentMethodsSsoLink.isPending ? "Opening..." : "Manage Cards"}
</Button> </Button>
<p className="text-sm text-gray-500"> <p className="text-sm text-gray-500">Opens in a new tab for security</p>
Opens in a new tab for security
</p>
</div> </div>
</div> </div>
)} )}
@ -232,7 +239,9 @@ export function PaymentMethodsContainer() {
</div> </div>
<div className="bg-gray-50 rounded-lg p-4 border border-gray-200"> <div className="bg-gray-50 rounded-lg p-4 border border-gray-200">
<h3 className="text-sm font-medium text-gray-800 mb-2">Supported Payment Methods</h3> <h3 className="text-sm font-medium text-gray-800 mb-2">
Supported Payment Methods
</h3>
<ul className="text-sm text-gray-600 space-y-1"> <ul className="text-sm text-gray-600 space-y-1">
<li> Credit Cards (Visa, MasterCard, American Express)</li> <li> Credit Cards (Visa, MasterCard, American Express)</li>
<li> Debit Cards</li> <li> Debit Cards</li>

View File

@ -241,8 +241,9 @@ export function AddressConfirmation({
if (!billingInfo) return null; if (!billingInfo) return null;
const address = billingInfo.address; const address = billingInfo.address;
const countryLabel = const countryLabel = address?.country
address?.country ? getCountryName(address.country) ?? address.country : null; ? (getCountryName(address.country) ?? address.country)
: null;
return wrap( return wrap(
<> <>
@ -368,7 +369,7 @@ export function AddressConfirmation({
{option.name} {option.name}
</option> </option>
))} ))}
</select> </select>
</div> </div>
<div className="flex items-center space-x-3 pt-4"> <div className="flex items-center space-x-3 pt-4">
@ -396,9 +397,7 @@ export function AddressConfirmation({
<div className="space-y-4"> <div className="space-y-4">
<div className="text-gray-900 space-y-1"> <div className="text-gray-900 space-y-1">
<p className="font-semibold text-base">{address.address1}</p> <p className="font-semibold text-base">{address.address1}</p>
{address.address2 ? ( {address.address2 ? <p className="text-gray-700">{address.address2}</p> : null}
<p className="text-gray-700">{address.address2}</p>
) : null}
<p className="text-gray-700"> <p className="text-gray-700">
{[address.city, address.state].filter(Boolean).join(", ")} {[address.city, address.state].filter(Boolean).join(", ")}
{address.postcode ? ` ${address.postcode}` : ""} {address.postcode ? ` ${address.postcode}` : ""}

View File

@ -256,7 +256,9 @@ export function EnhancedOrderSummary({
<div className="mb-4"> <div className="mb-4">
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">
<span className="text-gray-700">Tax (10%)</span> <span className="text-gray-700">Tax (10%)</span>
<span className="font-medium text-gray-900">¥{formatCurrency(totals.taxAmount)}</span> <span className="font-medium text-gray-900">
¥{formatCurrency(totals.taxAmount)}
</span>
</div> </div>
</div> </div>
)} )}

View File

@ -196,10 +196,9 @@ export function OrderSummary({
<span className="text-gray-600">{String(addon.name)}</span> <span className="text-gray-600">{String(addon.name)}</span>
<span className="font-medium"> <span className="font-medium">
¥ ¥
{( {(addon.billingCycle === "Monthly"
addon.billingCycle === "Monthly" ? (addon.monthlyPrice ?? 0)
? addon.monthlyPrice ?? 0 : (addon.oneTimePrice ?? 0)
: addon.oneTimePrice ?? 0
).toLocaleString()} ).toLocaleString()}
{addon.billingCycle === "Monthly" ? "/mo" : " one-time"} {addon.billingCycle === "Monthly" ? "/mo" : " one-time"}
</span> </span>

View File

@ -60,7 +60,6 @@ export function PricingDisplay({
infoText, infoText,
children, children,
}: PricingDisplayProps) { }: PricingDisplayProps) {
const getCurrencyIcon = () => { const getCurrencyIcon = () => {
if (!showCurrencySymbol) return null; if (!showCurrencySymbol) return null;
return currency === "JPY" ? <CurrencyYenIcon className="h-5 w-5" /> : "$"; return currency === "JPY" ? <CurrencyYenIcon className="h-5 w-5" /> : "$";

View File

@ -110,14 +110,17 @@ function OrderSummary({
{mode && <p className="text-sm text-gray-600">Access Mode: {mode}</p>} {mode && <p className="text-sm text-gray-600">Access Mode: {mode}</p>}
</div> </div>
<div className="text-right"> <div className="text-right">
<p className="font-semibold text-gray-900">¥{(plan.monthlyPrice ?? 0).toLocaleString()}</p> <p className="font-semibold text-gray-900">
¥{(plan.monthlyPrice ?? 0).toLocaleString()}
</p>
<p className="text-xs text-gray-500">per month</p> <p className="text-xs text-gray-500">per month</p>
</div> </div>
</div> </div>
</div> </div>
{/* Installation */} {/* Installation */}
{(selectedInstallation.monthlyPrice ?? 0) > 0 || (selectedInstallation.oneTimePrice ?? 0) > 0 ? ( {(selectedInstallation.monthlyPrice ?? 0) > 0 ||
(selectedInstallation.oneTimePrice ?? 0) > 0 ? (
<div className="border-t border-gray-200 pt-4 mb-6"> <div className="border-t border-gray-200 pt-4 mb-6">
<h4 className="font-medium text-gray-900 mb-3">Installation</h4> <h4 className="font-medium text-gray-900 mb-3">Installation</h4>
<div className="flex justify-between text-sm"> <div className="flex justify-between text-sm">

View File

@ -174,7 +174,8 @@ export function SimConfigureView({
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-2xl font-bold text-blue-600"> <div className="text-2xl font-bold text-blue-600">
¥{(plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0).toLocaleString()}/mo ¥{(plan.monthlyPrice ?? plan.unitPrice ?? plan.oneTimePrice ?? 0).toLocaleString()}
/mo
</div> </div>
{plan.simHasFamilyDiscount && ( {plan.simHasFamilyDiscount && (
<div className="text-sm text-green-600 font-medium">Discounted Price</div> <div className="text-sm text-green-600 font-medium">Discounted Price</div>

View File

@ -19,9 +19,7 @@ const parseActivationType = (value: string | null): ActivationType | null => {
return null; return null;
}; };
const parsePortingGender = ( const parsePortingGender = (value: string | null): MnpData["portingGender"] | undefined => {
value: string | null
): MnpData["portingGender"] | undefined => {
if (value === "Male" || value === "Female" || value === "Corporate/Other") { if (value === "Male" || value === "Female" || value === "Corporate/Other") {
return value; return value;
} }
@ -41,9 +39,7 @@ export function useInternetConfigureParams() {
const params = useSearchParams(); const params = useSearchParams();
const accessModeParam = params.get("accessMode"); const accessModeParam = params.get("accessMode");
const accessMode: AccessMode | null = const accessMode: AccessMode | null =
accessModeParam === "IPoE-BYOR" || accessModeParam === "PPPoE" accessModeParam === "IPoE-BYOR" || accessModeParam === "PPPoE" ? accessModeParam : null;
? accessModeParam
: null;
const installationSku = params.get("installationSku"); const installationSku = params.get("installationSku");
const addonSkus = params.getAll("addonSku"); const addonSkus = params.getAll("addonSku");
@ -59,11 +55,9 @@ export function useSimConfigureParams() {
const simType = parseSimCardType(params.get("simType")); const simType = parseSimCardType(params.get("simType"));
const activationType = parseActivationType(params.get("activationType")); const activationType = parseActivationType(params.get("activationType"));
const scheduledAt = const scheduledAt = coalesce(params.get("scheduledAt"), params.get("scheduledDate")) ?? null;
coalesce(params.get("scheduledAt"), params.get("scheduledDate")) ?? null;
const addonSkus = params.getAll("addonSku"); const addonSkus = params.getAll("addonSku");
const isMnp = const isMnp = coalesce(params.get("isMnp"), params.get("wantsMnp"))?.toLowerCase() === "true";
coalesce(params.get("isMnp"), params.get("wantsMnp"))?.toLowerCase() === "true";
const eid = params.get("eid") ?? null; const eid = params.get("eid") ?? null;
const mnp: Partial<MnpData> = { const mnp: Partial<MnpData> = {
@ -86,14 +80,8 @@ export function useSimConfigureParams() {
params.get("mvnoAccountNumber"), params.get("mvnoAccountNumber"),
params.get("mnp_mvnoAccountNumber") params.get("mnp_mvnoAccountNumber")
), ),
portingLastName: coalesce( portingLastName: coalesce(params.get("portingLastName"), params.get("mnp_portingLastName")),
params.get("portingLastName"), portingFirstName: coalesce(params.get("portingFirstName"), params.get("mnp_portingFirstName")),
params.get("mnp_portingLastName")
),
portingFirstName: coalesce(
params.get("portingFirstName"),
params.get("mnp_portingFirstName")
),
portingLastNameKatakana: coalesce( portingLastNameKatakana: coalesce(
params.get("portingLastNameKatakana"), params.get("portingLastNameKatakana"),
params.get("mnp_portingLastNameKatakana") params.get("mnp_portingLastNameKatakana")

View File

@ -10,9 +10,7 @@ import type {
} from "@customer-portal/domain/catalog"; } from "@customer-portal/domain/catalog";
type InstallationTerm = NonNullable< type InstallationTerm = NonNullable<
NonNullable<InternetInstallationCatalogItem["catalogMetadata"]>[ NonNullable<InternetInstallationCatalogItem["catalogMetadata"]>["installationTerm"]
"installationTerm"
]
>; >;
type InternetAccessMode = "IPoE-BYOR" | "PPPoE"; type InternetAccessMode = "IPoE-BYOR" | "PPPoE";

View File

@ -12,7 +12,10 @@ import {
type MnpData, type MnpData,
} from "@customer-portal/domain/sim"; } from "@customer-portal/domain/sim";
import { buildSimOrderConfigurations } from "@customer-portal/domain/orders"; import { buildSimOrderConfigurations } from "@customer-portal/domain/orders";
import type { SimCatalogProduct, SimActivationFeeCatalogItem } from "@customer-portal/domain/catalog"; import type {
SimCatalogProduct,
SimActivationFeeCatalogItem,
} from "@customer-portal/domain/catalog";
export type UseSimConfigureResult = { export type UseSimConfigureResult = {
// data // data
@ -73,9 +76,7 @@ const parseActivationTypeParam = (value: string | null): ActivationType | null =
return null; return null;
}; };
const parsePortingGenderParam = ( const parsePortingGenderParam = (value: string | null): MnpData["portingGender"] | undefined => {
value: string | null
): MnpData["portingGender"] | undefined => {
if (value === "Male" || value === "Female" || value === "Corporate/Other") { if (value === "Male" || value === "Female" || value === "Corporate/Other") {
return value; return value;
} }
@ -161,9 +162,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
const resolvedParams = useMemo(() => { const resolvedParams = useMemo(() => {
const initialSimType = const initialSimType =
configureParams.simType ?? configureParams.simType ?? parseSimCardTypeParam(parsedSearchParams.get("simType")) ?? "eSIM";
parseSimCardTypeParam(parsedSearchParams.get("simType")) ??
"eSIM";
const initialActivationType = const initialActivationType =
configureParams.activationType ?? configureParams.activationType ??
parseActivationTypeParam(parsedSearchParams.get("activationType")) ?? parseActivationTypeParam(parsedSearchParams.get("activationType")) ??
@ -203,14 +202,20 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
const mnp = configureParams.mnp; const mnp = configureParams.mnp;
const resolvedMnpData: MnpData = wantsMnp const resolvedMnpData: MnpData = wantsMnp
? { ? {
reservationNumber: paramFallback(mnp.reservationNumber, parsedSearchParams.get("mnpNumber")), reservationNumber: paramFallback(
mnp.reservationNumber,
parsedSearchParams.get("mnpNumber")
),
expiryDate: paramFallback(mnp.expiryDate, parsedSearchParams.get("mnpExpiry")), expiryDate: paramFallback(mnp.expiryDate, parsedSearchParams.get("mnpExpiry")),
phoneNumber: paramFallback(mnp.phoneNumber, parsedSearchParams.get("mnpPhone")), phoneNumber: paramFallback(mnp.phoneNumber, parsedSearchParams.get("mnpPhone")),
mvnoAccountNumber: paramFallback( mvnoAccountNumber: paramFallback(
mnp.mvnoAccountNumber, mnp.mvnoAccountNumber,
parsedSearchParams.get("mvnoAccountNumber") parsedSearchParams.get("mvnoAccountNumber")
), ),
portingLastName: paramFallback(mnp.portingLastName, parsedSearchParams.get("portingLastName")), portingLastName: paramFallback(
mnp.portingLastName,
parsedSearchParams.get("portingLastName")
),
portingFirstName: paramFallback( portingFirstName: paramFallback(
mnp.portingFirstName, mnp.portingFirstName,
parsedSearchParams.get("portingFirstName") parsedSearchParams.get("portingFirstName")
@ -413,10 +418,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
params.set("activationType", values.activationType); params.set("activationType", values.activationType);
if (values.scheduledActivationDate) { if (values.scheduledActivationDate) {
params.set("scheduledDate", values.scheduledActivationDate); params.set("scheduledDate", values.scheduledActivationDate);
params.set( params.set("scheduledAt", values.scheduledActivationDate.replace(/-/g, ""));
"scheduledAt",
values.scheduledActivationDate.replace(/-/g, "")
);
} else { } else {
params.delete("scheduledDate"); params.delete("scheduledDate");
params.delete("scheduledAt"); params.delete("scheduledAt");
@ -438,16 +440,13 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
if (simConfig.mnpPhone) params.set("mnpPhone", simConfig.mnpPhone); if (simConfig.mnpPhone) params.set("mnpPhone", simConfig.mnpPhone);
if (simConfig.mvnoAccountNumber) if (simConfig.mvnoAccountNumber)
params.set("mvnoAccountNumber", simConfig.mvnoAccountNumber); params.set("mvnoAccountNumber", simConfig.mvnoAccountNumber);
if (simConfig.portingLastName) if (simConfig.portingLastName) params.set("portingLastName", simConfig.portingLastName);
params.set("portingLastName", simConfig.portingLastName); if (simConfig.portingFirstName) params.set("portingFirstName", simConfig.portingFirstName);
if (simConfig.portingFirstName)
params.set("portingFirstName", simConfig.portingFirstName);
if (simConfig.portingLastNameKatakana) if (simConfig.portingLastNameKatakana)
params.set("portingLastNameKatakana", simConfig.portingLastNameKatakana); params.set("portingLastNameKatakana", simConfig.portingLastNameKatakana);
if (simConfig.portingFirstNameKatakana) if (simConfig.portingFirstNameKatakana)
params.set("portingFirstNameKatakana", simConfig.portingFirstNameKatakana); params.set("portingFirstNameKatakana", simConfig.portingFirstNameKatakana);
if (simConfig.portingGender) if (simConfig.portingGender) params.set("portingGender", simConfig.portingGender);
params.set("portingGender", simConfig.portingGender);
if (simConfig.portingDateOfBirth) if (simConfig.portingDateOfBirth)
params.set("portingDateOfBirth", simConfig.portingDateOfBirth); params.set("portingDateOfBirth", simConfig.portingDateOfBirth);
} else { } else {

View File

@ -33,13 +33,17 @@ export const catalogService = {
}, },
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> { async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
const response = await apiClient.GET<InternetInstallationCatalogItem[]>("/api/catalog/internet/installations"); const response = await apiClient.GET<InternetInstallationCatalogItem[]>(
"/api/catalog/internet/installations"
);
const data = getDataOrDefault<InternetInstallationCatalogItem[]>(response, []); const data = getDataOrDefault<InternetInstallationCatalogItem[]>(response, []);
return internetInstallationCatalogItemSchema.array().parse(data); return internetInstallationCatalogItemSchema.array().parse(data);
}, },
async getInternetAddons(): Promise<InternetAddonCatalogItem[]> { async getInternetAddons(): Promise<InternetAddonCatalogItem[]> {
const response = await apiClient.GET<InternetAddonCatalogItem[]>("/api/catalog/internet/addons"); const response = await apiClient.GET<InternetAddonCatalogItem[]>(
"/api/catalog/internet/addons"
);
const data = getDataOrDefault<InternetAddonCatalogItem[]>(response, []); const data = getDataOrDefault<InternetAddonCatalogItem[]>(response, []);
return internetAddonCatalogItemSchema.array().parse(data); return internetAddonCatalogItemSchema.array().parse(data);
}, },
@ -51,7 +55,9 @@ export const catalogService = {
}, },
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> { async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
const response = await apiClient.GET<SimActivationFeeCatalogItem[]>("/api/catalog/sim/activation-fees"); const response = await apiClient.GET<SimActivationFeeCatalogItem[]>(
"/api/catalog/sim/activation-fees"
);
const data = getDataOrDefault<SimActivationFeeCatalogItem[]>(response, []); const data = getDataOrDefault<SimActivationFeeCatalogItem[]>(response, []);
return simActivationFeeCatalogItemSchema.array().parse(data); return simActivationFeeCatalogItemSchema.array().parse(data);
}, },

View File

@ -30,4 +30,3 @@ export function getDisplayPrice(item: CatalogProductBase): PriceInfo | null {
currency, currency,
}; };
} }

View File

@ -130,102 +130,98 @@ export function InternetPlansContainer() {
</Button> </Button>
</div> </div>
{/* Enhanced Header */} {/* Enhanced Header */}
<div className="text-center mb-16 relative"> <div className="text-center mb-16 relative">
{/* Background decoration */} {/* Background decoration */}
<div className="absolute inset-0 overflow-hidden pointer-events-none"> <div className="absolute inset-0 overflow-hidden pointer-events-none">
<div className="absolute -top-20 -right-20 w-40 h-40 bg-gradient-to-br from-blue-400/10 to-indigo-600/10 rounded-full blur-3xl"></div> <div className="absolute -top-20 -right-20 w-40 h-40 bg-gradient-to-br from-blue-400/10 to-indigo-600/10 rounded-full blur-3xl"></div>
<div className="absolute -bottom-20 -left-20 w-40 h-40 bg-gradient-to-tr from-indigo-400/10 to-purple-600/10 rounded-full blur-3xl"></div> <div className="absolute -bottom-20 -left-20 w-40 h-40 bg-gradient-to-tr from-indigo-400/10 to-purple-600/10 rounded-full blur-3xl"></div>
</div>
<h1 className="text-5xl font-bold bg-gradient-to-r from-gray-900 via-blue-900 to-indigo-900 bg-clip-text text-transparent mb-6 relative">
Choose Your Internet Plan
</h1>
<p className="text-xl text-gray-600 mb-8 max-w-3xl mx-auto leading-relaxed">
High-speed fiber internet with reliable connectivity for your home or business
</p>
{eligibility && (
<div className="mt-8">
<div
className={`inline-flex items-center gap-3 px-8 py-4 rounded-2xl border backdrop-blur-sm shadow-lg hover:shadow-xl transition-all duration-300 ${getEligibilityColor(eligibility)}`}
>
{getEligibilityIcon(eligibility)}
<span className="font-semibold text-lg">Available for: {eligibility}</span>
</div>
<p className="text-gray-600 mt-4 max-w-2xl mx-auto leading-relaxed">
Plans shown are tailored to your house type and local infrastructure
</p>
</div>
)}
</div> </div>
<h1 className="text-5xl font-bold bg-gradient-to-r from-gray-900 via-blue-900 to-indigo-900 bg-clip-text text-transparent mb-6 relative"> {hasActiveInternet && (
Choose Your Internet Plan <AlertBanner
</h1> variant="warning"
<p className="text-xl text-gray-600 mb-8 max-w-3xl mx-auto leading-relaxed"> title="You already have an Internet subscription"
High-speed fiber internet with reliable connectivity for your home or business className="mb-8"
</p> >
<p>
{eligibility && ( You already have an Internet subscription with us. If you want another subscription
<div className="mt-8"> for a different residence, please{" "}
<div <a href="/support/new" className="underline text-blue-700 hover:text-blue-600">
className={`inline-flex items-center gap-3 px-8 py-4 rounded-2xl border backdrop-blur-sm shadow-lg hover:shadow-xl transition-all duration-300 ${getEligibilityColor(eligibility)}`} contact us
> </a>
{getEligibilityIcon(eligibility)} .
<span className="font-semibold text-lg">Available for: {eligibility}</span>
</div>
<p className="text-gray-600 mt-4 max-w-2xl mx-auto leading-relaxed">
Plans shown are tailored to your house type and local infrastructure
</p> </p>
</AlertBanner>
)}
{plans.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{plans.map(plan => (
<InternetPlanCard
key={plan.id}
plan={plan}
installations={installations}
disabled={hasActiveInternet}
disabledReason={
hasActiveInternet
? "Already subscribed — contact us to add another residence"
: undefined
}
/>
))}
</div>
<AlertBanner variant="info" title="Important Notes" className="mt-12">
<ul className="list-disc list-inside space-y-1">
<li>Theoretical internet speed is the same for all three packages</li>
<li>
One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments
</li>
<li>
Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans
(¥450/month + ¥1,000-3,000 one-time)
</li>
<li>In-home technical assistance available (¥15,000 onsite visiting fee)</li>
</ul>
</AlertBanner>
</>
) : (
<div className="text-center py-12">
<ServerIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No Plans Available</h3>
<p className="text-gray-600 mb-6">
We couldn&apos;t find any internet plans available for your location at this time.
</p>
<Button as="a" href="/catalog" leftIcon={<ArrowLeftIcon className="w-4 h-4" />}>
Back to Services
</Button>
</div> </div>
)} )}
</div> </div>
{hasActiveInternet && (
<AlertBanner
variant="warning"
title="You already have an Internet subscription"
className="mb-8"
>
<p>
You already have an Internet subscription with us. If you want another subscription
for a different residence, please{" "}
<a href="/support/new" className="underline text-blue-700 hover:text-blue-600">
contact us
</a>
.
</p>
</AlertBanner>
)}
{plans.length > 0 ? (
<>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{plans.map(plan => (
<InternetPlanCard
key={plan.id}
plan={plan}
installations={installations}
disabled={hasActiveInternet}
disabledReason={
hasActiveInternet
? "Already subscribed — contact us to add another residence"
: undefined
}
/>
))}
</div>
<AlertBanner variant="info" title="Important Notes" className="mt-12">
<ul className="list-disc list-inside space-y-1">
<li>Theoretical internet speed is the same for all three packages</li>
<li>
One-time fee (¥22,800) can be paid upfront or in 12- or 24-month installments
</li>
<li>
Home phone line (Hikari Denwa) can be added to GOLD or PLATINUM plans (¥450/month
+ ¥1,000-3,000 one-time)
</li>
<li>In-home technical assistance available (¥15,000 onsite visiting fee)</li>
</ul>
</AlertBanner>
</>
) : (
<div className="text-center py-12">
<ServerIcon className="h-12 w-12 text-gray-400 mx-auto mb-4" />
<h3 className="text-lg font-medium text-gray-900 mb-2">No Plans Available</h3>
<p className="text-gray-600 mb-6">
We couldn&apos;t find any internet plans available for your location at this time.
</p>
<Button
as="a"
href="/catalog"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
>
Back to Services
</Button>
</div>
)}
</div>
</PageLayout> </PageLayout>
</div> </div>
); );

View File

@ -7,7 +7,11 @@ import { ordersService } from "@/features/orders/services/orders.service";
import { usePaymentMethods } from "@/features/billing/hooks/useBilling"; import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh"; import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
import type { CatalogProductBase } from "@customer-portal/domain/catalog"; import type { CatalogProductBase } from "@customer-portal/domain/catalog";
import { createLoadingState, createSuccessState, createErrorState } from "@customer-portal/domain/toolkit"; import {
createLoadingState,
createSuccessState,
createErrorState,
} from "@customer-portal/domain/toolkit";
import type { AsyncState } from "@customer-portal/domain/toolkit"; import type { AsyncState } from "@customer-portal/domain/toolkit";
import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions"; import { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
import { import {
@ -261,8 +265,7 @@ export function useCheckout() {
); );
} catch (error) { } catch (error) {
if (mounted) { if (mounted) {
const reason = const reason = error instanceof Error ? error.message : "Failed to load checkout data";
error instanceof Error ? error.message : "Failed to load checkout data";
setCheckoutState(createErrorState(new Error(reason))); setCheckoutState(createErrorState(new Error(reason)));
} }
} }
@ -301,7 +304,10 @@ export function useCheckout() {
? { simType: selections.simType as OrderConfigurations["simType"] } ? { simType: selections.simType as OrderConfigurations["simType"] }
: {}), : {}),
...(selections.activationType ...(selections.activationType
? { activationType: selections.activationType as OrderConfigurations["activationType"] } ? {
activationType:
selections.activationType as OrderConfigurations["activationType"],
}
: {}), : {}),
...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}), ...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}),
...(selections.eid ? { eid: selections.eid } : {}), ...(selections.eid ? { eid: selections.eid } : {}),
@ -313,7 +319,9 @@ export function useCheckout() {
? { mvnoAccountNumber: selections.mvnoAccountNumber } ? { mvnoAccountNumber: selections.mvnoAccountNumber }
: {}), : {}),
...(selections.portingLastName ? { portingLastName: selections.portingLastName } : {}), ...(selections.portingLastName ? { portingLastName: selections.portingLastName } : {}),
...(selections.portingFirstName ? { portingFirstName: selections.portingFirstName } : {}), ...(selections.portingFirstName
? { portingFirstName: selections.portingFirstName }
: {}),
...(selections.portingLastNameKatakana ...(selections.portingLastNameKatakana
? { portingLastNameKatakana: selections.portingLastNameKatakana } ? { portingLastNameKatakana: selections.portingLastNameKatakana }
: {}), : {}),
@ -366,7 +374,9 @@ export function useCheckout() {
if (orderType === ORDER_TYPE.SIM) { if (orderType === ORDER_TYPE.SIM) {
if (!configurations) { if (!configurations) {
throw new Error("SIM configuration is incomplete. Please restart the SIM configuration flow."); throw new Error(
"SIM configuration is incomplete. Please restart the SIM configuration flow."
);
} }
if (configurations?.simType === "eSIM" && !configurations.eid) { if (configurations?.simType === "eSIM" && !configurations.eid) {
throw new Error( throw new Error(

View File

@ -42,10 +42,7 @@ export function useDashboardSummary() {
try { try {
const response = await apiClient.GET<DashboardSummary>("/api/me/summary"); const response = await apiClient.GET<DashboardSummary>("/api/me/summary");
if (!response.data) { if (!response.data) {
throw new DashboardDataError( throw new DashboardDataError("FETCH_ERROR", "Dashboard summary response was empty");
"FETCH_ERROR",
"Dashboard summary response was empty"
);
} }
const parsed = dashboardSummarySchema.safeParse(response.data); const parsed = dashboardSummarySchema.safeParse(response.data);
if (!parsed.success) { if (!parsed.success) {

View File

@ -6,10 +6,7 @@ import {
type OrderDetails, type OrderDetails,
type OrderSummary, type OrderSummary,
} from "@customer-portal/domain/orders"; } from "@customer-portal/domain/orders";
import { import { assertSuccess, type DomainApiResponse } from "@/lib/api/response-helpers";
assertSuccess,
type DomainApiResponse,
} from "@/lib/api/response-helpers";
async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: string }> { async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: string }> {
const body: CreateOrderRequest = { const body: CreateOrderRequest = {

View File

@ -186,9 +186,7 @@ export function OrderDetailContainer() {
<div> <div>
<div className="font-medium text-gray-900">{productName}</div> <div className="font-medium text-gray-900">{productName}</div>
<div className="text-xs text-gray-500">SKU: {sku}</div> <div className="text-xs text-gray-500">SKU: {sku}</div>
{billingCycle && ( {billingCycle && <div className="text-xs text-gray-500">{billingCycle}</div>}
<div className="text-xs text-gray-500">{billingCycle}</div>
)}
</div> </div>
<div className="text-right"> <div className="text-right">
<div className="text-sm text-gray-600">Qty: {item.quantity}</div> <div className="text-sm text-gray-600">Qty: {item.quantity}</div>

View File

@ -48,13 +48,7 @@ const formatQuota = (remainingMb: number) => {
return `${remainingMb.toFixed(0)} MB`; return `${remainingMb.toFixed(0)} MB`;
}; };
const FeatureToggleRow = ({ const FeatureToggleRow = ({ label, enabled }: { label: string; enabled: boolean }) => (
label,
enabled,
}: {
label: string;
enabled: boolean;
}) => (
<div className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg"> <div className="flex items-center justify-between py-2 px-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-700">{label}</span> <span className="text-sm text-gray-700">{label}</span>
<span <span
@ -118,9 +112,7 @@ export function SimDetailsCard({
<DevicePhoneMobileIcon className="h-5 w-5 text-gray-500" /> <DevicePhoneMobileIcon className="h-5 w-5 text-gray-500" />
); );
const statusClass = statusBadgeClass[normalizedStatus] ?? "bg-gray-100 text-gray-800"; const statusClass = statusBadgeClass[normalizedStatus] ?? "bg-gray-100 text-gray-800";
const containerClasses = embedded const containerClasses = embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100";
? ""
: "bg-white shadow-lg rounded-xl border border-gray-100";
return ( return (
<div className={`${containerClasses} ${embedded ? "" : "p-6 lg:p-8"}`}> <div className={`${containerClasses} ${embedded ? "" : "p-6 lg:p-8"}`}>

View File

@ -111,9 +111,7 @@ export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps
<div className="grid grid-cols-2 gap-4 text-sm"> <div className="grid grid-cols-2 gap-4 text-sm">
<div> <div>
<p className="text-gray-500">Price</p> <p className="text-gray-500">Price</p>
<p className="font-semibold text-gray-900"> <p className="font-semibold text-gray-900">{formatCurrency(subscription.amount)}</p>
{formatCurrency(subscription.amount)}
</p>
<p className="text-xs text-gray-500">{getBillingCycleLabel(subscription.cycle)}</p> <p className="text-xs text-gray-500">{getBillingCycleLabel(subscription.cycle)}</p>
</div> </div>
<div> <div>
@ -171,9 +169,7 @@ export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps
<div className="flex items-center space-x-6 text-sm"> <div className="flex items-center space-x-6 text-sm">
<div className="text-right"> <div className="text-right">
<p className="font-semibold text-gray-900"> <p className="font-semibold text-gray-900">{formatCurrency(subscription.amount)}</p>
{formatCurrency(subscription.amount)}
</p>
<p className="text-gray-500">{getBillingCycleLabel(subscription.cycle)}</p> <p className="text-gray-500">{getBillingCycleLabel(subscription.cycle)}</p>
</div> </div>

View File

@ -3,7 +3,7 @@ import { simInfoSchema, type SimInfo } from "@customer-portal/domain/sim";
import type { import type {
SimTopUpRequest, SimTopUpRequest,
SimPlanChangeRequest, SimPlanChangeRequest,
SimCancelRequest SimCancelRequest,
} from "@customer-portal/domain/sim"; } from "@customer-portal/domain/sim";
// Types imported from domain - no duplication // Types imported from domain - no duplication

View File

@ -22,14 +22,12 @@ import { useSubscription } from "@/features/subscriptions/hooks";
import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList"; import { InvoicesList } from "@/features/billing/components/InvoiceList/InvoiceList";
import { Formatting } from "@customer-portal/domain/toolkit"; import { Formatting } from "@customer-portal/domain/toolkit";
const { formatCurrency: sharedFormatCurrency, getCurrencyLocale } = Formatting; const { formatCurrency: sharedFormatCurrency } = Formatting;
import { SimManagementSection } from "@/features/sim-management"; import { SimManagementSection } from "@/features/sim-management";
export function SubscriptionDetailContainer() { export function SubscriptionDetailContainer() {
const params = useParams(); const params = useParams();
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 10;
const [showInvoices, setShowInvoices] = useState(true); const [showInvoices, setShowInvoices] = useState(true);
const [showSimManagement, setShowSimManagement] = useState(false); const [showSimManagement, setShowSimManagement] = useState(false);
@ -74,51 +72,6 @@ export function SubscriptionDetailContainer() {
} }
}; };
const getStatusColor = (status: string) => {
switch (status) {
case "Active":
return "bg-green-100 text-green-800";
case "Suspended":
return "bg-yellow-100 text-yellow-800";
case "Terminated":
return "bg-red-100 text-red-800";
case "Cancelled":
return "bg-gray-100 text-gray-800";
case "Pending":
return "bg-blue-100 text-blue-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const getInvoiceStatusIcon = (status: string) => {
switch (status) {
case "Paid":
return <CheckCircleIcon className="h-5 w-5 text-green-500" />;
case "Overdue":
return <ExclamationTriangleIcon className="h-5 w-5 text-red-500" />;
case "Unpaid":
return <ClockIcon className="h-5 w-5 text-yellow-500" />;
default:
return <DocumentTextIcon className="h-5 w-5 text-gray-500" />;
}
};
const getInvoiceStatusColor = (status: string) => {
switch (status) {
case "Paid":
return "bg-green-100 text-green-800";
case "Overdue":
return "bg-red-100 text-red-800";
case "Unpaid":
return "bg-yellow-100 text-yellow-800";
case "Cancelled":
return "bg-gray-100 text-gray-800";
default:
return "bg-gray-100 text-gray-800";
}
};
const formatDate = (dateString: string | undefined) => { const formatDate = (dateString: string | undefined) => {
if (!dateString) return "N/A"; if (!dateString) return "N/A";
try { try {
@ -128,8 +81,7 @@ export function SubscriptionDetailContainer() {
} }
}; };
const formatCurrency = (amount: number) => const formatCurrency = (amount: number) => sharedFormatCurrency(amount || 0);
sharedFormatCurrency(amount || 0);
const formatBillingLabel = (cycle: string) => { const formatBillingLabel = (cycle: string) => {
switch (cycle) { switch (cycle) {

View File

@ -11,7 +11,6 @@ import { StatusPill } from "@/components/atoms/status-pill";
import { SubCard } from "@/components/molecules/SubCard/SubCard"; import { SubCard } from "@/components/molecules/SubCard/SubCard";
import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar"; import { SearchFilterBar } from "@/components/molecules/SearchFilterBar/SearchFilterBar";
import { LoadingTable } from "@/components/atoms/loading-skeleton"; import { LoadingTable } from "@/components/atoms/loading-skeleton";
import { ErrorState } from "@/components/atoms/error-state";
import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock"; import { AsyncBlock } from "@/components/molecules/AsyncBlock/AsyncBlock";
import { import {
ServerIcon, ServerIcon,
@ -23,7 +22,6 @@ import {
} from "@heroicons/react/24/outline"; } from "@heroicons/react/24/outline";
import { format } from "date-fns"; import { format } from "date-fns";
import { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks"; import { useSubscriptions, useSubscriptionStats } from "@/features/subscriptions/hooks";
import { Formatting } from "@customer-portal/domain/toolkit";
import type { Subscription } from "@customer-portal/domain/subscriptions"; import type { Subscription } from "@customer-portal/domain/subscriptions";
import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency"; import { useFormatCurrency } from "@/lib/hooks/useFormatCurrency";
@ -135,9 +133,7 @@ export function SubscriptionsListContainer() {
header: "Price", header: "Price",
render: (s: Subscription) => ( render: (s: Subscription) => (
<div> <div>
<span className="text-sm font-medium text-gray-900"> <span className="text-sm font-medium text-gray-900">{formatCurrency(s.amount)}</span>
{formatCurrency(s.amount)}
</span>
<div className="text-xs text-gray-500"> <div className="text-xs text-gray-500">
{s.cycle === "Monthly" {s.cycle === "Monthly"
? "per month" ? "per month"

View File

@ -19,10 +19,7 @@ export function getDataOrThrow<T>(
/** /**
* Extract data from API response or return default value * Extract data from API response or return default value
*/ */
export function getDataOrDefault<T>( export function getDataOrDefault<T>(response: { data?: T; error?: unknown }, defaultValue: T): T {
response: { data?: T; error?: unknown },
defaultValue: T
): T {
return response.data ?? defaultValue; return response.data ?? defaultValue;
} }
@ -32,4 +29,3 @@ export function getDataOrDefault<T>(
export function isApiError(error: unknown): error is Error { export function isApiError(error: unknown): error is Error {
return error instanceof Error; return error instanceof Error;
} }

View File

@ -1,5 +1,11 @@
export { createClient, resolveBaseUrl } from "./runtime/client"; export { createClient, resolveBaseUrl } from "./runtime/client";
export type { ApiClient, AuthHeaderResolver, CreateClientOptions, QueryParams, PathParams } from "./runtime/client"; export type {
ApiClient,
AuthHeaderResolver,
CreateClientOptions,
QueryParams,
PathParams,
} from "./runtime/client";
export { ApiError, isApiError } from "./runtime/client"; export { ApiError, isApiError } from "./runtime/client";
// Re-export API helpers // Re-export API helpers

View File

@ -34,12 +34,9 @@ export function getNullableData<T>(response: ApiResponse<T>): T | null {
/** /**
* Extract data from API response or throw error * Extract data from API response or throw error
*/ */
export function getDataOrThrow<T>( export function getDataOrThrow<T>(response: ApiResponse<T>, errorMessage?: string): T {
response: ApiResponse<T>,
errorMessage?: string
): T {
if (response.error || response.data === undefined) { if (response.error || response.data === undefined) {
throw new Error(errorMessage || 'Failed to fetch data'); throw new Error(errorMessage || "Failed to fetch data");
} }
return response.data; return response.data;
} }
@ -47,10 +44,7 @@ export function getDataOrThrow<T>(
/** /**
* Extract data from API response or return default value * Extract data from API response or return default value
*/ */
export function getDataOrDefault<T>( export function getDataOrDefault<T>(response: ApiResponse<T>, defaultValue: T): T {
response: ApiResponse<T>,
defaultValue: T
): T {
return response.data ?? defaultValue; return response.data ?? defaultValue;
} }
@ -77,7 +71,10 @@ export function assertSuccess<T>(response: DomainApiResponse<T>): ApiSuccessResp
throw new Error(response.error.message); throw new Error(response.error.message);
} }
export function parseDomainResponse<T>(response: DomainApiResponse<T>, parser?: (payload: T) => T): T { export function parseDomainResponse<T>(
response: DomainApiResponse<T>,
parser?: (payload: T) => T
): T {
const success = assertSuccess(response); const success = assertSuccess(response);
return parser ? parser(success.data) : success.data; return parser ? parser(success.data) : success.data;
} }

View File

@ -13,14 +13,7 @@ export class ApiError extends Error {
export const isApiError = (error: unknown): error is ApiError => error instanceof ApiError; export const isApiError = (error: unknown): error is ApiError => error instanceof ApiError;
export type HttpMethod = export type HttpMethod = "GET" | "POST" | "PUT" | "PATCH" | "DELETE" | "HEAD" | "OPTIONS";
| "GET"
| "POST"
| "PUT"
| "PATCH"
| "DELETE"
| "HEAD"
| "OPTIONS";
export type PathParams = Record<string, string | number>; export type PathParams = Record<string, string | number>;
export type QueryPrimitive = string | number | boolean; export type QueryPrimitive = string | number | boolean;

View File

@ -13,11 +13,7 @@ const normalizedCountries = countries
return { return {
code, code,
name: commonName, name: commonName,
searchKeys: [ searchKeys: [commonName, country.name.official, ...(country.altSpellings ?? [])]
commonName,
country.name.official,
...(country.altSpellings ?? []),
]
.filter(Boolean) .filter(Boolean)
.map(entry => entry.toLowerCase()), .map(entry => entry.toLowerCase()),
}; };

View File

@ -8,7 +8,7 @@
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools"; import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
import { useState } from "react"; import { useState } from "react";
import { ApiError, isApiError } from "@/lib/api/runtime/client"; import { isApiError } from "@/lib/api/runtime/client";
export function QueryProvider({ children }: { children: React.ReactNode }) { export function QueryProvider({ children }: { children: React.ReactNode }) {
const [queryClient] = useState( const [queryClient] = useState(

View File

@ -139,7 +139,7 @@ function parseClientApiError(error: ClientApiError): ApiErrorInfo | null {
const status = error.response?.status; const status = error.response?.status;
const parsedBody = parseRawErrorBody(error.body); const parsedBody = parseRawErrorBody(error.body);
const payloadInfo = parsedBody ? deriveInfoFromPayload(parsedBody, status) : null; const payloadInfo = parsedBody ? deriveInfoFromPayload(parsedBody, status) : null;
if (payloadInfo) { if (payloadInfo) {
return payloadInfo; return payloadInfo;