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

@ -1,14 +1,13 @@
/** /**
* Centralized DB Mappers * Centralized DB Mappers
* *
* All mappers that transform Prisma entities to Domain types * All mappers that transform Prisma entities to Domain types
* *
* Pattern: * Pattern:
* - mapPrisma{Entity}ToDomain() - converts Prisma type to domain type * - mapPrisma{Entity}ToDomain() - converts Prisma type to domain type
* - These are infrastructure concerns (Prisma is BFF's implementation detail) * - These are infrastructure concerns (Prisma is BFF's implementation detail)
* - 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

@ -1,8 +1,8 @@
/** /**
* ID Mapping DB Mapper * ID Mapping DB Mapper
* *
* Maps Prisma IdMapping entity to Domain UserIdMapping type * Maps Prisma IdMapping entity to Domain UserIdMapping type
* *
* NOTE: This is an infrastructure concern - Prisma is BFF's ORM implementation detail. * NOTE: This is an infrastructure concern - Prisma is BFF's ORM implementation detail.
* Domain layer should not know about Prisma types. * Domain layer should not know about Prisma types.
*/ */
@ -23,4 +23,3 @@ export function mapPrismaMappingToDomain(mapping: PrismaIdMapping): UserIdMappin
updatedAt: mapping.updatedAt, updatedAt: mapping.updatedAt,
}; };
} }

View File

@ -1,8 +1,8 @@
/** /**
* User DB Mapper * User DB Mapper
* *
* Adapts @prisma/client User to domain UserAuth type * Adapts @prisma/client User to domain UserAuth type
* *
* NOTE: This is an infrastructure adapter - Prisma is BFF's ORM implementation detail. * NOTE: This is an infrastructure adapter - Prisma is BFF's ORM implementation detail.
* The domain provider handles the actual mapping logic. * The domain provider handles the actual mapping logic.
*/ */
@ -15,10 +15,10 @@ type PrismaUserRaw = Parameters<typeof CustomerProviders.Portal.mapPrismaUserToU
/** /**
* Maps Prisma User entity to Domain UserAuth type * Maps Prisma User entity to Domain UserAuth type
* *
* This adapter converts the @prisma/client User to the domain's PrismaUserRaw type, * This adapter converts the @prisma/client User to the domain's PrismaUserRaw type,
* then uses the domain portal provider mapper to get UserAuth. * then uses the domain portal provider mapper to get UserAuth.
* *
* NOTE: UserAuth contains ONLY auth state. Profile data comes from WHMCS. * NOTE: UserAuth contains ONLY auth state. Profile data comes from WHMCS.
* For complete user profile, use UsersService.getProfile() which fetches from WHMCS. * For complete user profile, use UsersService.getProfile() which fetches from WHMCS.
*/ */
@ -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

@ -12,11 +12,11 @@ import type { SalesforceOrderRecord } from "@customer-portal/domain/orders";
* Account Methods (Actually Used): * Account Methods (Actually Used):
* - findAccountByCustomerNumber() - Used in signup/WHMCS linking workflows * - findAccountByCustomerNumber() - Used in signup/WHMCS linking workflows
* - getAccountDetails() - Used in signup to check WH_Account__c field * - getAccountDetails() - Used in signup to check WH_Account__c field
* *
* Order Methods: * Order Methods:
* - updateOrder() - Used in order provisioning * - updateOrder() - Used in order provisioning
* - getOrder() - Used to fetch order details * - getOrder() - Used to fetch order details
* *
* Note: Internet Eligibility checking happens in internet-catalog.service.ts * Note: Internet Eligibility checking happens in internet-catalog.service.ts
*/ */
@Injectable() @Injectable()

View File

@ -8,7 +8,7 @@ import { customerNumberSchema, salesforceIdSchema } from "@customer-portal/domai
/** /**
* Salesforce Account Service * Salesforce Account Service
* *
* Only contains methods that are actually used in the codebase: * Only contains methods that are actually used in the codebase:
* - findByCustomerNumber() - Used in signup/WHMCS linking workflows * - findByCustomerNumber() - Used in signup/WHMCS linking workflows
* - getAccountDetails() - Used in signup to check WH_Account__c field * - getAccountDetails() - Used in signup to check WH_Account__c field

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

@ -19,7 +19,7 @@ export class WhmcsCurrencyService implements OnModuleInit {
// Check if WHMCS is available before trying to load currencies // Check if WHMCS is available before trying to load currencies
this.logger.debug("Checking WHMCS availability before loading currencies"); this.logger.debug("Checking WHMCS availability before loading currencies");
const isAvailable = await this.connectionService.isAvailable(); const isAvailable = await this.connectionService.isAvailable();
if (!isAvailable) { if (!isAvailable) {
this.logger.warn("WHMCS service is not available, using fallback currency configuration"); this.logger.warn("WHMCS service is not available, using fallback currency configuration");
this.setFallbackCurrency(); this.setFallbackCurrency();
@ -49,9 +49,9 @@ export class WhmcsCurrencyService implements OnModuleInit {
format: "1", format: "1",
rate: "1.00000", rate: "1.00000",
}; };
this.currencies = [this.defaultCurrency]; this.currencies = [this.defaultCurrency];
this.logger.log("Using fallback currency configuration", { this.logger.log("Using fallback currency configuration", {
defaultCurrency: this.defaultCurrency.code, defaultCurrency: this.defaultCurrency.code,
}); });
@ -94,13 +94,13 @@ 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)) {
// Parse the WHMCS response format into currency objects // Parse the WHMCS response format into currency objects
this.currencies = this.parseWhmcsCurrenciesResponse(response); this.currencies = this.parseWhmcsCurrenciesResponse(response);
if (this.currencies.length > 0) { if (this.currencies.length > 0) {
// Set first currency as default (WHMCS typically returns the primary currency first) // Set first currency as default (WHMCS typically returns the primary currency first)
this.defaultCurrency = this.currencies[0]; this.defaultCurrency = this.currencies[0];
@ -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", {
@ -136,21 +138,25 @@ export class WhmcsCurrencyService implements OnModuleInit {
*/ */
private parseWhmcsCurrenciesResponse(response: WhmcsCurrenciesResponse): WhmcsCurrency[] { private parseWhmcsCurrenciesResponse(response: WhmcsCurrenciesResponse): WhmcsCurrency[] {
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 (
const currencyArray = Array.isArray(response.currencies.currency) response.currencies &&
? response.currencies.currency typeof response.currencies === "object" &&
"currency" in response.currencies
) {
const currencyArray = Array.isArray(response.currencies.currency)
? response.currencies.currency
: [response.currencies.currency]; : [response.currencies.currency];
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 };
@ -180,7 +177,7 @@ export class WhmcsOrderService {
/** /**
* Build WHMCS AddOrder payload from our parameters * Build WHMCS AddOrder payload from our parameters
* Following official WHMCS API documentation format * Following official WHMCS API documentation format
* *
* Delegates to shared mapper function from integration package * Delegates to shared mapper function from integration package
*/ */
private buildAddOrderPayload(params: WhmcsAddOrderParams): Record<string, unknown> { private buildAddOrderPayload(params: WhmcsAddOrderParams): Record<string, unknown> {

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";
@ -137,7 +135,7 @@ export class SignupWorkflowService {
if (request) { if (request) {
await this.authRateLimitService.consumeSignupAttempt(request); await this.authRateLimitService.consumeSignupAttempt(request);
} }
// Validate signup data using schema (throws on validation error) // Validate signup data using schema (throws on validation error)
signupRequestSchema.parse(signupData); signupRequestSchema.parse(signupData);
@ -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,14 +16,23 @@ 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
* *
* All request validation is handled by Zod schemas via ZodValidationPipe. * All request validation is handled by Zod schemas via ZodValidationPipe.
* Business logic is delegated to service layer. * Business logic is delegated to service layer.
*/ */
@ -88,7 +97,7 @@ export class InvoicesController {
): Subscription[] { ): Subscription[] {
// Validate using domain schema // Validate using domain schema
invoiceSchema.shape.id.parse(invoiceId); invoiceSchema.shape.id.parse(invoiceId);
// This functionality has been moved to WHMCS directly // This functionality has been moved to WHMCS directly
// For now, return empty array as subscriptions are managed in WHMCS // For now, return empty array as subscriptions are managed in WHMCS
return []; return [];

View File

@ -9,18 +9,14 @@ import { InvoiceHealthService } from "./services/invoice-health.service";
/** /**
* Invoice Module * Invoice Module
* *
* Validation is now handled by Zod schemas via ZodValidationPipe in controller. * Validation is now handled by Zod schemas via ZodValidationPipe in controller.
* No separate validator service needed. * No separate validator service needed.
*/ */
@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

@ -25,7 +25,7 @@ interface UserMappingInfo {
/** /**
* Service responsible for retrieving invoices from WHMCS * Service responsible for retrieving invoices from WHMCS
* *
* Validation is handled by Zod schemas at the entry point (controller). * Validation is handled by Zod schemas at the entry point (controller).
* This service focuses on business logic and data fetching. * This service focuses on business logic and data fetching.
*/ */
@ -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);
} }
@ -190,7 +199,7 @@ export class InvoiceRetrievalService {
private async getUserMapping(userId: string): Promise<UserMappingInfo> { private async getUserMapping(userId: string): Promise<UserMappingInfo> {
// Validate userId is a valid UUID // Validate userId is a valid UUID
validateUuidV4OrThrow(userId); validateUuidV4OrThrow(userId);
const mapping = await this.mappingsService.findByUserId(userId); const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) { if (!mapping?.whmcsClientId) {

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

@ -1,6 +1,6 @@
/** /**
* BFF Invoice Monitoring Types * BFF Invoice Monitoring Types
* *
* Infrastructure types for monitoring, health checks, and statistics. * Infrastructure types for monitoring, health checks, and statistics.
* These are BFF-specific and do not belong in the domain layer. * These are BFF-specific and do not belong in the domain layer.
*/ */
@ -24,4 +24,3 @@ export interface InvoiceHealthStatus {
timestamp: string; timestamp: string;
}; };
} }

View File

@ -28,7 +28,7 @@ import { ProvisioningProcessor } from "./queue/provisioning.processor";
providers: [ providers: [
// Shared services // Shared services
PaymentValidatorService, PaymentValidatorService,
// Order creation services (modular) // Order creation services (modular)
OrderValidator, OrderValidator,
OrderBuilder, OrderBuilder,

View File

@ -32,7 +32,7 @@ export class ProvisioningProcessor extends WorkerHost {
}); });
// Guard: Only process if Salesforce Order is currently 'Activating' // Guard: Only process if Salesforce Order is currently 'Activating'
const order = await this.salesforceService.getOrder(sfOrderId); const order = await this.salesforceService.getOrder(sfOrderId);
const status = order?.Activation_Status__c ?? ""; const status = order?.Activation_Status__c ?? "";
const lastErrorCode = order?.Activation_Error_Code__c ?? ""; const lastErrorCode = order?.Activation_Error_Code__c ?? "";

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

@ -5,7 +5,7 @@ import type { OrderFulfillmentErrorCode } from "@customer-portal/domain/orders";
/** /**
* Centralized error code determination and error handling for order fulfillment * Centralized error code determination and error handling for order fulfillment
* Eliminates duplicate error code logic across services * Eliminates duplicate error code logic across services
* *
* Note: Error codes are now defined in @customer-portal/domain/orders as business constants * Note: Error codes are now defined in @customer-portal/domain/orders as business constants
*/ */
@Injectable() @Injectable()

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",
@ -169,13 +161,13 @@ export class OrderFulfillmentOrchestrator {
// Use domain mapper directly - single transformation! // Use domain mapper directly - single transformation!
const result = OrderProviders.Whmcs.mapOrderToWhmcsItems(context.orderDetails); const result = OrderProviders.Whmcs.mapOrderToWhmcsItems(context.orderDetails);
mappingResult = result; mappingResult = result;
this.logger.log("OrderItems mapped to WHMCS", { this.logger.log("OrderItems mapped to WHMCS", {
totalItems: result.summary.totalItems, totalItems: result.summary.totalItems,
serviceItems: result.summary.serviceItems, serviceItems: result.summary.serviceItems,
activationItems: result.summary.activationItems, activationItems: result.summary.activationItems,
}); });
return Promise.resolve(result); return Promise.resolve(result);
}, },
critical: true, critical: true,
@ -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
*/ */
@ -371,7 +357,6 @@ export class OrderFulfillmentOrchestrator {
): Promise<void> { ): Promise<void> {
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,

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");
@ -52,7 +51,7 @@ export class OrderFulfillmentValidator {
const sfOrder = await this.validateSalesforceOrder(sfOrderId); const sfOrder = await this.validateSalesforceOrder(sfOrderId);
// 2. Check if already provisioned (idempotency) // 2. Check if already provisioned (idempotency)
const existingWhmcsOrderId = sfOrder.WHMCS_Order_ID__c; const existingWhmcsOrderId = sfOrder.WHMCS_Order_ID__c;
if (existingWhmcsOrderId) { if (existingWhmcsOrderId) {
this.logger.log("Order already provisioned", { this.logger.log("Order already provisioned", {
@ -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;
@ -101,7 +98,7 @@ export class OrderOrchestrator {
const sfAccountId = userMapping.sfAccountId const sfAccountId = userMapping.sfAccountId
? assertSalesforceId(userMapping.sfAccountId, "sfAccountId") ? assertSalesforceId(userMapping.sfAccountId, "sfAccountId")
: undefined; : undefined;
if (!sfAccountId) { if (!sfAccountId) {
this.logger.warn({ userId }, "User mapping missing Salesforce account ID"); this.logger.warn({ userId }, "User mapping missing Salesforce account ID");
return []; return [];

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

@ -18,7 +18,7 @@ import { PaymentValidatorService } from "./payment-validator.service";
/** /**
* Handles all order validation logic - both format and business rules * Handles all order validation logic - both format and business rules
* *
* Note: Business validation rules have been moved to @customer-portal/domain/orders/validation * Note: Business validation rules have been moved to @customer-portal/domain/orders/validation
* This service now focuses on infrastructure concerns (DB lookups, API calls, etc.) * This service now focuses on infrastructure concerns (DB lookups, API calls, etc.)
*/ */

View File

@ -5,7 +5,7 @@ import { getErrorMessage } from "@bff/core/utils/error.util";
/** /**
* Shared Payment Validation Service * Shared Payment Validation Service
* *
* Provides consistent payment method validation across order workflows. * Provides consistent payment method validation across order workflows.
* Used by both OrderValidator and OrderFulfillmentValidator. * Used by both OrderValidator and OrderFulfillmentValidator.
*/ */
@ -18,35 +18,34 @@ export class PaymentValidatorService {
/** /**
* Validate that a payment method exists for the WHMCS client * Validate that a payment method exists for the WHMCS client
* *
* @param userId - User ID for logging purposes * @param userId - User ID for logging purposes
* @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 : [];
if (paymentMethods.length === 0) { if (paymentMethods.length === 0) {
this.logger.warn({ userId, whmcsClientId }, "No payment method on file"); this.logger.warn({ userId, whmcsClientId }, "No payment method on file");
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) {
throw e; throw e;
} }
const err = getErrorMessage(e); const err = getErrorMessage(e);
this.logger.error({ err, userId, whmcsClientId }, "Payment method verification failed"); this.logger.error({ err, userId, whmcsClientId }, "Payment method verification failed");
throw new BadRequestException("Unable to verify payment method. Please try again later."); throw new BadRequestException("Unable to verify payment method. Please try again later.");
} }
} }
} }

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 {
@ -14,7 +18,7 @@ export class SimValidationService {
/** /**
* Check if a subscription is a SIM service and extract account identifier * Check if a subscription is a SIM service and extract account identifier
* *
* Uses domain validation functions for business logic. * Uses domain validation functions for business logic.
*/ */
async validateSimSubscription( async validateSimSubscription(

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

@ -63,7 +63,7 @@ export class UsersController {
* PATCH /me - Update customer profile (can update profile fields and/or address) * PATCH /me - Update customer profile (can update profile fields and/or address)
* All fields optional - only send what needs to be updated * All fields optional - only send what needs to be updated
* Updates stored in WHMCS (single source of truth) * Updates stored in WHMCS (single source of truth)
* *
* Examples: * Examples:
* - Update name only: { firstname: "John", lastname: "Doe" } * - Update name only: { firstname: "John", lastname: "Doe" }
* - Update address only: { address1: "123 Main St", city: "Tokyo" } * - Update address only: { address1: "123 Main St", city: "Tokyo" }
@ -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()
@ -54,7 +48,7 @@ export class UsersService {
const user = await this.prisma.user.findUnique({ const user = await this.prisma.user.findUnique({
where: { email: validEmail }, where: { email: validEmail },
}); });
if (!user) return null; if (!user) return null;
// Return full profile with WHMCS data // Return full profile with WHMCS data
@ -134,10 +128,10 @@ export class UsersService {
try { try {
// Get WHMCS client data (source of truth for profile) // Get WHMCS client data (source of truth for profile)
const whmcsClient = await this.whmcsService.getClientDetails(mapping.whmcsClientId); const whmcsClient = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
// Map Prisma user to UserAuth // Map Prisma user to UserAuth
const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user); const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user);
return buildUserProfile(userAuth, whmcsClient); return buildUserProfile(userAuth, whmcsClient);
} catch (error) { } catch (error) {
this.logger.error("Failed to fetch client profile from WHMCS", { this.logger.error("Failed to fetch client profile from WHMCS", {
@ -223,7 +217,7 @@ export class UsersService {
const createdUser = await this.prisma.user.create({ const createdUser = await this.prisma.user.create({
data: normalizedData, data: normalizedData,
}); });
// Return full profile from WHMCS // Return full profile from WHMCS
return this.getProfile(createdUser.id); return this.getProfile(createdUser.id);
} catch (error) { } catch (error) {
@ -274,15 +268,18 @@ export class UsersService {
// Update in WHMCS (all fields optional) // Update in WHMCS (all fields optional)
await this.whmcsService.updateClient(mapping.whmcsClientId, parsed); await this.whmcsService.updateClient(mapping.whmcsClientId, parsed);
this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS"); this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS");
// Return fresh profile // Return fresh profile
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: ", ""));
} }
@ -321,7 +318,7 @@ export class UsersService {
const mapping = await this.mappingsService.findByUserId(userId); const mapping = await this.mappingsService.findByUserId(userId);
if (!mapping?.whmcsClientId) { if (!mapping?.whmcsClientId) {
this.logger.warn(`No WHMCS mapping found for user ${userId}`); this.logger.warn(`No WHMCS mapping found for user ${userId}`);
// Get currency from WHMCS profile if available // Get currency from WHMCS profile if available
let currency = "JPY"; // Default let currency = "JPY"; // Default
try { try {
@ -333,7 +330,7 @@ export class UsersService {
error: getErrorMessage(error), error: getErrorMessage(error),
}); });
} }
const summary: DashboardSummary = { const summary: DashboardSummary = {
stats: { stats: {
activeSubscriptions: 0, activeSubscriptions: 0,
@ -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

@ -24,7 +24,7 @@ interface LoginFormProps {
/** /**
* Frontend form schema - extends domain loginRequestSchema with UI-specific fields * Frontend form schema - extends domain loginRequestSchema with UI-specific fields
* *
* Single source of truth: Domain layer (loginRequestSchema) defines validation rules * Single source of truth: Domain layer (loginRequestSchema) defines validation rules
* Frontend only adds: rememberMe field for UI state * Frontend only adds: rememberMe field for UI state
*/ */
@ -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 {
@ -38,7 +35,7 @@ export function PasswordResetForm({
// Zod form for password reset request - uses domain schema // Zod form for password reset request - uses domain schema
type PasswordResetRequestData = z.infer<typeof passwordResetRequestSchema>; type PasswordResetRequestData = z.infer<typeof passwordResetRequestSchema>;
const requestForm = useZodForm<PasswordResetRequestData>({ const requestForm = useZodForm<PasswordResetRequestData>({
schema: passwordResetRequestSchema, schema: passwordResetRequestSchema,
initialValues: { email: "" }, initialValues: { email: "" },
@ -56,7 +53,7 @@ export function PasswordResetForm({
/** /**
* Frontend reset form schema - extends domain passwordResetSchema with confirmPassword * Frontend reset form schema - extends domain passwordResetSchema with confirmPassword
* *
* Single source of truth: Domain layer defines validation rules * Single source of truth: Domain layer defines validation rules
* Frontend only adds: confirmPassword field and password matching logic * Frontend only adds: confirmPassword field and password matching logic
*/ */
@ -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

@ -33,7 +33,7 @@ export function SetPasswordForm({
/** /**
* Frontend form schema - extends domain setPasswordRequestSchema with confirmPassword * Frontend form schema - extends domain setPasswordRequestSchema with confirmPassword
* *
* Single source of truth: Domain layer defines validation rules * Single source of truth: Domain layer defines validation rules
* Frontend only adds: confirmPassword field and password matching logic * Frontend only adds: confirmPassword field and password matching logic
*/ */
@ -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

@ -28,7 +28,7 @@ interface SignupFormProps {
/** /**
* Frontend form schema - extends domain signupInputSchema with UI-specific fields * Frontend form schema - extends domain signupInputSchema with UI-specific fields
* *
* Single source of truth: Domain layer (signupInputSchema) defines all validation rules * Single source of truth: Domain layer (signupInputSchema) defines all validation rules
* Frontend only adds: confirmPassword field and password matching logic * Frontend only adds: confirmPassword field and password matching logic
*/ */
@ -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

@ -23,27 +23,39 @@ export function InvoiceItems({ items = [], currency }: InvoiceItemsProps) {
const renderItemContent = (item: InvoiceItem, index: number) => { const renderItemContent = (item: InvoiceItem, index: number) => {
const isLinked = hasServiceConnection(item); const isLinked = hasServiceConnection(item);
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">
@ -106,14 +110,14 @@ export function InvoiceSummaryBar({
<div className="text-lg font-medium text-slate-500 uppercase tracking-wide"> <div className="text-lg font-medium text-slate-500 uppercase tracking-wide">
{invoice.currency?.toUpperCase()} {invoice.currency?.toUpperCase()}
</div> </div>
<StatusPill <StatusPill
size="md" size="md"
variant={statusVariant} variant={statusVariant}
label={statusLabel} label={statusLabel}
className="font-semibold" className="font-semibold"
/> />
</div> </div>
{/* Due date information */} {/* Due date information */}
{(dueDisplay || relativeDue) && ( {(dueDisplay || relativeDue) && (
<div className="flex items-center gap-2 text-sm text-slate-600"> <div className="flex items-center gap-2 text-sm text-slate-600">
@ -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

@ -16,7 +16,7 @@ export function InvoiceTotals({ subtotal, tax, total }: InvoiceTotalsProps) {
<div className="bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border border-slate-200 shadow-sm overflow-hidden"> <div className="bg-gradient-to-br from-slate-50 to-slate-100 rounded-2xl border border-slate-200 shadow-sm overflow-hidden">
<div className="px-8 py-6"> <div className="px-8 py-6">
<h3 className="text-lg font-semibold text-slate-900 mb-6">Invoice Summary</h3> <h3 className="text-lg font-semibold text-slate-900 mb-6">Invoice Summary</h3>
<div className="space-y-4"> <div className="space-y-4">
<div className="flex justify-between items-center text-slate-600"> <div className="flex justify-between items-center text-slate-600">
<span className="font-medium">Subtotal</span> <span className="font-medium">Subtotal</span>

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,16 +96,14 @@ 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
</span> </span>
)} )}
</div> </div>
{/* Controls */} {/* Controls */}
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
{/* Search Input */} {/* Search Input */}
@ -150,9 +149,9 @@ export function InvoicesList({
{/* Invoice Table */} {/* Invoice Table */}
<div className="bg-white rounded-xl border border-gray-200/60 shadow-sm overflow-hidden"> <div className="bg-white rounded-xl border border-gray-200/60 shadow-sm overflow-hidden">
<InvoiceTable <InvoiceTable
invoices={filtered} invoices={filtered}
loading={isLoading} loading={isLoading}
compact={compact} compact={compact}
className="border-0 rounded-none shadow-none" className="border-0 rounded-none shadow-none"
/> />

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";
@ -75,9 +72,9 @@ export function InvoiceTable({
event.stopPropagation(); // Prevent row click event.stopPropagation(); // Prevent row click
setPaymentLoading(invoice.id); setPaymentLoading(invoice.id);
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) {
@ -91,9 +88,9 @@ export function InvoiceTable({
event.stopPropagation(); // Prevent row click event.stopPropagation(); // Prevent row click
setDownloadLoading(invoice.id); setDownloadLoading(invoice.id);
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}
@ -155,7 +148,7 @@ export function InvoiceTable({
)} )}
</div> </div>
); );
case "Overdue": case "Overdue":
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@ -164,12 +157,12 @@ 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>
); );
case "Unpaid": case "Unpaid":
return ( return (
<div className="space-y-2"> <div className="space-y-2">
@ -183,18 +176,14 @@ export function InvoiceTable({
)} )}
</div> </div>
); );
default: default:
// Use the existing BillingStatusBadge for other statuses // Use the existing BillingStatusBadge for other statuses
return <BillingStatusBadge status={invoice.status} />; return <BillingStatusBadge status={invoice.status} />;
} }
}; };
return ( return <div className="py-3">{renderStatusWithDate()}</div>;
<div className="py-3">
{renderStatusWithDate()}
</div>
);
}, },
}, },
{ {
@ -221,7 +210,7 @@ export function InvoiceTable({
const canPay = invoice.status === "Unpaid" || invoice.status === "Overdue"; const canPay = invoice.status === "Unpaid" || invoice.status === "Overdue";
const isPaymentLoading = paymentLoading === invoice.id; const isPaymentLoading = paymentLoading === invoice.id;
const isDownloadLoading = downloadLoading === invoice.id; const isDownloadLoading = downloadLoading === invoice.id;
return ( return (
<div className="py-3 flex justify-end items-center space-x-2"> <div className="py-3 flex justify-end items-center space-x-2">
{/* Payment Button - Only for unpaid invoices - Always on the left */} {/* Payment Button - Only for unpaid invoices - Always on the left */}
@ -238,7 +227,7 @@ export function InvoiceTable({
Pay Now Pay Now
</Button> </Button>
)} )}
{/* Download Button - Always available and always on the right */} {/* Download Button - Always available and always on the right */}
<Button <Button
size="sm" size="sm"
@ -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";
@ -14,19 +19,22 @@ interface PaymentMethodCardProps {
const getBrandColor = (brand?: string) => { 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";
return "from-gray-500 to-gray-600"; // Default return "from-gray-500 to-gray-600"; // Default
}; };
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 (
<div className={`${baseClasses} from-green-500 to-green-600`}> <div className={`${baseClasses} from-green-500 to-green-600`}>
@ -34,10 +42,12 @@ const getMethodIcon = (type: PaymentMethod["type"], brand?: string) => {
</div> </div>
); );
} }
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}`}>
<IconComponent className="h-6 w-6 text-white" /> <IconComponent className="h-6 w-6 text-white" />
@ -45,10 +55,10 @@ const getMethodIcon = (type: PaymentMethod["type"], brand?: string) => {
); );
}; };
const isCreditCard = (type: PaymentMethod["type"]) => const isCreditCard = (type: PaymentMethod["type"]) =>
type === "CreditCard" || type === "RemoteCreditCard"; type === "CreditCard" || type === "RemoteCreditCard";
const isBankAccount = (type: PaymentMethod["type"]) => const isBankAccount = (type: PaymentMethod["type"]) =>
type === "BankAccount" || type === "RemoteBankAccount"; type === "BankAccount" || type === "RemoteBankAccount";
const formatCardDisplay = (method: PaymentMethod) => { const formatCardDisplay = (method: PaymentMethod) => {
@ -64,7 +74,7 @@ const formatCardDisplay = (method: PaymentMethod) => {
if (isBankAccount(method.type)) { if (isBankAccount(method.type)) {
return method.bankName || "Bank Account"; return method.bankName || "Bank Account";
} }
return method.description || "Payment Method"; return method.description || "Payment Method";
}; };
@ -76,7 +86,7 @@ const formatCardBrand = (method: PaymentMethod) => {
if (isBankAccount(method.type) && method.bankName) { if (isBankAccount(method.type) && method.bankName) {
return method.bankName; return method.bankName;
} }
return null; return null;
}; };
@ -116,11 +126,9 @@ export function PaymentMethodCard({
</div> </div>
)} )}
</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>}
@ -128,7 +136,7 @@ export function PaymentMethodCard({
</> </>
)} )}
</div> </div>
{paymentMethod.isDefault && ( {paymentMethod.isDefault && (
<div className="text-xs text-blue-600 font-medium mt-1"> <div className="text-xs text-blue-600 font-medium mt-1">
This card will be used for automatic payments This card will be used for automatic payments
@ -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,10 +10,10 @@ import {
} from "@tanstack/react-query"; } from "@tanstack/react-query";
// ✅ Generic utilities from lib // ✅ Generic utilities from lib
import { import {
apiClient, apiClient,
queryKeys, queryKeys,
getDataOrDefault, getDataOrDefault,
getDataOrThrow, getDataOrThrow,
type QueryParams, type QueryParams,
} from "@/lib/api"; } from "@/lib/api";

View File

@ -181,7 +181,7 @@ export function InvoiceDetailContainer() {
<div className="space-y-8"> <div className="space-y-8">
{/* Invoice Items */} {/* Invoice Items */}
<InvoiceItems items={invoice.items} currency={invoice.currency} /> <InvoiceItems items={invoice.items} currency={invoice.currency} />
{/* Invoice Summary - Full Width */} {/* Invoice Summary - Full Width */}
<div className="border-t border-slate-200 pt-8"> <div className="border-t border-slate-200 pt-8">
<InvoiceTotals <InvoiceTotals

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,
}); });
@ -54,14 +57,21 @@ export function PaymentMethodsContainer() {
} }
setError(null); setError(null);
try { try {
const ssoLink = await createPaymentMethodsSsoLink.mutateAsync(); const ssoLink = await createPaymentMethodsSsoLink.mutateAsync();
openSsoLink(ssoLink.url, { newTab: true }); openSsoLink(ssoLink.url, { newTab: true });
} 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.");
@ -107,7 +117,7 @@ export function PaymentMethodsContainer() {
text={paymentRefresh.toast.text} text={paymentRefresh.toast.text}
tone={paymentRefresh.toast.tone} tone={paymentRefresh.toast.tone}
/> />
<div className="grid grid-cols-1 lg:grid-cols-4 xl:grid-cols-3 gap-6"> <div className="grid grid-cols-1 lg:grid-cols-4 xl:grid-cols-3 gap-6">
<div className="lg:col-span-3 xl:col-span-2"> <div className="lg:col-span-3 xl:col-span-2">
{!hasCheckedAuth || isLoadingPaymentMethods || isFetchingPaymentMethods ? ( {!hasCheckedAuth || isLoadingPaymentMethods || isFetchingPaymentMethods ? (
@ -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

@ -118,11 +118,11 @@ export function InternetPlansContainer() {
<div className="max-w-6xl mx-auto"> <div className="max-w-6xl mx-auto">
{/* Enhanced Back Button */} {/* Enhanced Back Button */}
<div className="mb-8"> <div className="mb-8">
<Button <Button
as="a" as="a"
href="/catalog" href="/catalog"
variant="outline" variant="outline"
size="sm" size="sm"
className="group bg-white/80 backdrop-blur-sm border-white/50 shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-0.5" className="group bg-white/80 backdrop-blur-sm border-white/50 shadow-lg hover:shadow-xl transition-all duration-300 hover:-translate-y-0.5"
leftIcon={<ArrowLeftIcon className="w-4 h-4" />} leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
> >
@ -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> </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 && ( <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">
<div className="mt-8"> Choose Your Internet Plan
<div </h1>
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)}`} <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
{getEligibilityIcon(eligibility)} </p>
<span className="font-semibold text-lg">Available for: {eligibility}</span>
{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>
<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 </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> </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

@ -14,7 +14,7 @@ interface UpcomingPaymentBannerProps {
export function UpcomingPaymentBanner({ invoice, onPay, loading }: UpcomingPaymentBannerProps) { export function UpcomingPaymentBanner({ invoice, onPay, loading }: UpcomingPaymentBannerProps) {
const { formatCurrency } = useFormatCurrency(); const { formatCurrency } = useFormatCurrency();
return ( return (
<div id="attention" className="bg-white rounded-xl border border-orange-200 shadow-sm p-4"> <div id="attention" className="bg-white rounded-xl border border-orange-200 shadow-sm p-4">
<div className="flex items-center gap-4"> <div className="flex items-center gap-4">

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

@ -78,7 +78,7 @@ const getBillingCycleLabel = (cycle: string) => {
export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps>( export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps>(
({ subscription, variant = "list", showActions = true, onViewClick, className }, ref) => { ({ subscription, variant = "list", showActions = true, onViewClick, className }, ref) => {
const { formatCurrency } = useFormatCurrency(); const { formatCurrency } = useFormatCurrency();
const handleViewClick = () => { const handleViewClick = () => {
if (onViewClick) { if (onViewClick) {
onViewClick(subscription); onViewClick(subscription);
@ -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

@ -104,7 +104,7 @@ const isVpnService = (productName: string) => {
export const SubscriptionDetails = forwardRef<HTMLDivElement, SubscriptionDetailsProps>( export const SubscriptionDetails = forwardRef<HTMLDivElement, SubscriptionDetailsProps>(
({ subscription, showServiceSpecificSections = true, className }, ref) => { ({ subscription, showServiceSpecificSections = true, className }, ref) => {
const { formatCurrency } = useFormatCurrency(); const { formatCurrency } = useFormatCurrency();
return ( return (
<div ref={ref} className={cn("space-y-6", className)}> <div ref={ref} className={cn("space-y-6", className)}>
{/* Main Details Card */} {/* Main Details Card */}

Some files were not shown because too many files have changed in this diff Show More