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:
parent
6567bc5907
commit
e5ce4e166c
374
DOMAIN_VIOLATIONS_REPORT.md
Normal file
374
DOMAIN_VIOLATIONS_REPORT.md
Normal 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/`
|
||||
@ -1,14 +1,13 @@
|
||||
/**
|
||||
* Centralized DB Mappers
|
||||
*
|
||||
*
|
||||
* All mappers that transform Prisma entities to Domain types
|
||||
*
|
||||
*
|
||||
* Pattern:
|
||||
* - mapPrisma{Entity}ToDomain() - converts Prisma type to domain type
|
||||
* - These are infrastructure concerns (Prisma is BFF's implementation detail)
|
||||
* - Domain layer should never import these
|
||||
*/
|
||||
|
||||
export * from './user.mapper';
|
||||
export * from './mapping.mapper';
|
||||
|
||||
export * from "./user.mapper";
|
||||
export * from "./mapping.mapper";
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
/**
|
||||
* ID Mapping DB Mapper
|
||||
*
|
||||
*
|
||||
* Maps Prisma IdMapping entity to Domain UserIdMapping type
|
||||
*
|
||||
*
|
||||
* NOTE: This is an infrastructure concern - Prisma is BFF's ORM implementation detail.
|
||||
* Domain layer should not know about Prisma types.
|
||||
*/
|
||||
@ -23,4 +23,3 @@ export function mapPrismaMappingToDomain(mapping: PrismaIdMapping): UserIdMappin
|
||||
updatedAt: mapping.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -1,8 +1,8 @@
|
||||
/**
|
||||
* User DB Mapper
|
||||
*
|
||||
*
|
||||
* Adapts @prisma/client User to domain UserAuth type
|
||||
*
|
||||
*
|
||||
* NOTE: This is an infrastructure adapter - Prisma is BFF's ORM implementation detail.
|
||||
* 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
|
||||
*
|
||||
*
|
||||
* This adapter converts the @prisma/client User to the domain's PrismaUserRaw type,
|
||||
* then uses the domain portal provider mapper to get UserAuth.
|
||||
*
|
||||
*
|
||||
* NOTE: UserAuth contains ONLY auth state. Profile data comes 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
|
||||
return CustomerProviders.Portal.mapPrismaUserToUserAuth(prismaUserRaw);
|
||||
}
|
||||
|
||||
|
||||
@ -85,8 +85,7 @@ export class FreebitAuthService {
|
||||
}
|
||||
|
||||
const json: unknown = await response.json();
|
||||
const data: FreebitAuthResponse =
|
||||
FreebitProvider.mapper.transformFreebitAuthResponse(json);
|
||||
const data: FreebitAuthResponse = FreebitProvider.mapper.transformFreebitAuthResponse(json);
|
||||
|
||||
if (data.resultCode !== "100" || !data.authKey) {
|
||||
throw new FreebitError(
|
||||
|
||||
@ -80,10 +80,10 @@ export class FreebitOperationsService {
|
||||
if (ep !== candidates[0]) {
|
||||
this.logger.warn(`Retrying Freebit account details with alternative endpoint: ${ep}`);
|
||||
}
|
||||
response = await this.client.makeAuthenticatedRequest<FreebitAccountDetailsRaw, typeof request>(
|
||||
ep,
|
||||
request
|
||||
);
|
||||
response = await this.client.makeAuthenticatedRequest<
|
||||
FreebitAccountDetailsRaw,
|
||||
typeof request
|
||||
>(ep, request);
|
||||
break;
|
||||
} catch (err: unknown) {
|
||||
lastError = err;
|
||||
@ -116,7 +116,9 @@ export class FreebitOperationsService {
|
||||
*/
|
||||
async getSimUsage(account: string): Promise<SimUsage> {
|
||||
try {
|
||||
const request: FreebitTrafficInfoRequest = FreebitProvider.schemas.trafficInfo.parse({ account });
|
||||
const request: FreebitTrafficInfoRequest = FreebitProvider.schemas.trafficInfo.parse({
|
||||
account,
|
||||
});
|
||||
|
||||
const response = await this.client.makeAuthenticatedRequest<
|
||||
FreebitTrafficInfoRaw,
|
||||
@ -143,7 +145,11 @@ export class FreebitOperationsService {
|
||||
options: { campaignCode?: string; expiryDate?: string; scheduledAt?: string } = {}
|
||||
): Promise<void> {
|
||||
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 baseRequest = {
|
||||
account: payload.account,
|
||||
@ -158,10 +164,7 @@ export class FreebitOperationsService {
|
||||
? { ...baseRequest, runTime: payload.options?.scheduledAt }
|
||||
: baseRequest;
|
||||
|
||||
await this.client.makeAuthenticatedRequest<TopUpResponse, typeof request>(
|
||||
endpoint,
|
||||
request
|
||||
);
|
||||
await this.client.makeAuthenticatedRequest<TopUpResponse, typeof request>(endpoint, request);
|
||||
|
||||
this.logger.log(`Successfully topped up ${quotaMb}MB for account ${account}`, {
|
||||
account,
|
||||
@ -238,17 +241,20 @@ export class FreebitOperationsService {
|
||||
runTime: parsed.scheduledAt,
|
||||
};
|
||||
|
||||
const response = await this.client.makeAuthenticatedRequest<PlanChangeResponse, typeof request>(
|
||||
"/mvno/changePlan/",
|
||||
request
|
||||
);
|
||||
const response = await this.client.makeAuthenticatedRequest<
|
||||
PlanChangeResponse,
|
||||
typeof request
|
||||
>("/mvno/changePlan/", request);
|
||||
|
||||
this.logger.log(`Successfully changed plan for account ${parsed.account} to ${parsed.newPlanCode}`, {
|
||||
account: parsed.account,
|
||||
newPlanCode: parsed.newPlanCode,
|
||||
assignGlobalIp: parsed.assignGlobalIp,
|
||||
scheduled: Boolean(parsed.scheduledAt),
|
||||
});
|
||||
this.logger.log(
|
||||
`Successfully changed plan for account ${parsed.account} to ${parsed.newPlanCode}`,
|
||||
{
|
||||
account: parsed.account,
|
||||
newPlanCode: parsed.newPlanCode,
|
||||
assignGlobalIp: parsed.assignGlobalIp,
|
||||
scheduled: Boolean(parsed.scheduledAt),
|
||||
}
|
||||
);
|
||||
|
||||
return {
|
||||
ipv4: response.ipv4,
|
||||
@ -426,17 +432,20 @@ export class FreebitOperationsService {
|
||||
authKey: await this.auth.getAuthKey(),
|
||||
};
|
||||
|
||||
await this.client.makeAuthenticatedRequest<EsimAddAccountResponse, FreebitEsimAddAccountRequest>(
|
||||
"/mvno/esim/addAcnt/",
|
||||
payload
|
||||
);
|
||||
await this.client.makeAuthenticatedRequest<
|
||||
EsimAddAccountResponse,
|
||||
FreebitEsimAddAccountRequest
|
||||
>("/mvno/esim/addAcnt/", payload);
|
||||
|
||||
this.logger.log(`Successfully reissued eSIM profile via addAcnt for account ${parsed.account}`, {
|
||||
account: parsed.account,
|
||||
newEid: parsed.newEid,
|
||||
oldProductNumber: parsed.oldProductNumber,
|
||||
oldEid: parsed.oldEid,
|
||||
});
|
||||
this.logger.log(
|
||||
`Successfully reissued eSIM profile via addAcnt for account ${parsed.account}`,
|
||||
{
|
||||
account: parsed.account,
|
||||
newEid: parsed.newEid,
|
||||
oldProductNumber: parsed.oldProductNumber,
|
||||
oldEid: parsed.oldEid,
|
||||
}
|
||||
);
|
||||
} catch (error) {
|
||||
const message = getErrorMessage(error);
|
||||
this.logger.error(`Failed to reissue eSIM profile via addAcnt for account ${account}`, {
|
||||
@ -480,16 +489,17 @@ export class FreebitOperationsService {
|
||||
} = params;
|
||||
|
||||
// Import schemas dynamically to avoid circular dependencies
|
||||
const validatedParams: FreebitEsimActivationParams = FreebitProvider.schemas.esimActivationParams.parse({
|
||||
account,
|
||||
eid,
|
||||
planCode,
|
||||
contractLine,
|
||||
aladinOperated,
|
||||
shipDate,
|
||||
mnp,
|
||||
identity,
|
||||
});
|
||||
const validatedParams: FreebitEsimActivationParams =
|
||||
FreebitProvider.schemas.esimActivationParams.parse({
|
||||
account,
|
||||
eid,
|
||||
planCode,
|
||||
contractLine,
|
||||
aladinOperated,
|
||||
shipDate,
|
||||
mnp,
|
||||
identity,
|
||||
});
|
||||
|
||||
if (!validatedParams.account || !validatedParams.eid) {
|
||||
throw new BadRequestException("activateEsimAccountNew requires account and eid");
|
||||
|
||||
@ -5,9 +5,7 @@ import type { SimDetails, SimUsage, SimTopUpHistory } from "@customer-portal/dom
|
||||
|
||||
@Injectable()
|
||||
export class FreebitOrchestratorService {
|
||||
constructor(
|
||||
private readonly operations: FreebitOperationsService
|
||||
) {}
|
||||
constructor(private readonly operations: FreebitOperationsService) {}
|
||||
|
||||
/**
|
||||
* Get SIM account details
|
||||
|
||||
@ -12,11 +12,11 @@ import type { SalesforceOrderRecord } from "@customer-portal/domain/orders";
|
||||
* Account Methods (Actually Used):
|
||||
* - findAccountByCustomerNumber() - Used in signup/WHMCS linking workflows
|
||||
* - getAccountDetails() - Used in signup to check WH_Account__c field
|
||||
*
|
||||
*
|
||||
* Order Methods:
|
||||
* - updateOrder() - Used in order provisioning
|
||||
* - getOrder() - Used to fetch order details
|
||||
*
|
||||
*
|
||||
* Note: Internet Eligibility checking happens in internet-catalog.service.ts
|
||||
*/
|
||||
@Injectable()
|
||||
|
||||
@ -8,7 +8,7 @@ import { customerNumberSchema, salesforceIdSchema } from "@customer-portal/domai
|
||||
|
||||
/**
|
||||
* Salesforce Account Service
|
||||
*
|
||||
*
|
||||
* Only contains methods that are actually used in the codebase:
|
||||
* - findByCustomerNumber() - Used in signup/WHMCS linking workflows
|
||||
* - getAccountDetails() - Used in signup to check WH_Account__c field
|
||||
|
||||
@ -55,10 +55,9 @@ export class SalesforceOrderService {
|
||||
const orderItemProduct2Fields = buildOrderItemProduct2Fields().map(
|
||||
f => `PricebookEntry.Product2.${f}`
|
||||
);
|
||||
const orderItemSelect = [
|
||||
...buildOrderItemSelectFields(),
|
||||
...orderItemProduct2Fields,
|
||||
].join(", ");
|
||||
const orderItemSelect = [...buildOrderItemSelectFields(), ...orderItemProduct2Fields].join(
|
||||
", "
|
||||
);
|
||||
|
||||
const orderSoql = `
|
||||
SELECT ${orderQueryFields}
|
||||
@ -96,10 +95,7 @@ export class SalesforceOrderService {
|
||||
);
|
||||
|
||||
// Use domain mapper - single transformation!
|
||||
return OrderProviders.Salesforce.transformSalesforceOrderDetails(
|
||||
order,
|
||||
orderItems
|
||||
);
|
||||
return OrderProviders.Salesforce.transformSalesforceOrderDetails(order, orderItems);
|
||||
} catch (error: unknown) {
|
||||
this.logger.error("Failed to fetch order with items", {
|
||||
error: getErrorMessage(error),
|
||||
@ -140,10 +136,9 @@ export class SalesforceOrderService {
|
||||
const orderItemProduct2Fields = buildOrderItemProduct2Fields().map(
|
||||
f => `PricebookEntry.Product2.${f}`
|
||||
);
|
||||
const orderItemSelect = [
|
||||
...buildOrderItemSelectFields(),
|
||||
...orderItemProduct2Fields,
|
||||
].join(", ");
|
||||
const orderItemSelect = [...buildOrderItemSelectFields(), ...orderItemProduct2Fields].join(
|
||||
", "
|
||||
);
|
||||
|
||||
const ordersSoql = `
|
||||
SELECT ${orderQueryFields}
|
||||
@ -211,8 +206,8 @@ export class SalesforceOrderService {
|
||||
|
||||
// Use domain mapper for each order - single transformation!
|
||||
return orders
|
||||
.filter((order): order is SalesforceOrderRecord & { Id: string } =>
|
||||
typeof order.Id === "string"
|
||||
.filter(
|
||||
(order): order is SalesforceOrderRecord & { Id: string } => typeof order.Id === "string"
|
||||
)
|
||||
.map(order =>
|
||||
OrderProviders.Salesforce.transformSalesforceOrderSummary(
|
||||
@ -229,4 +224,3 @@ export class SalesforceOrderService {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -17,17 +17,8 @@ export function buildProductQuery(
|
||||
additionalFields: string[] = [],
|
||||
additionalConditions: string = ""
|
||||
): string {
|
||||
const categoryField = assertSoqlFieldName(
|
||||
portalCategoryField,
|
||||
"PRODUCT_PORTAL_CATEGORY_FIELD"
|
||||
);
|
||||
const baseFields = [
|
||||
"Id",
|
||||
"Name",
|
||||
"StockKeepingUnit",
|
||||
categoryField,
|
||||
"Item_Class__c",
|
||||
];
|
||||
const categoryField = assertSoqlFieldName(portalCategoryField, "PRODUCT_PORTAL_CATEGORY_FIELD");
|
||||
const baseFields = ["Id", "Name", "StockKeepingUnit", categoryField, "Item_Class__c"];
|
||||
const allFields = [...baseFields, ...additionalFields].join(", ");
|
||||
|
||||
const safeCategory = sanitizeSoqlLiteral(category);
|
||||
|
||||
@ -10,9 +10,7 @@ const UNIQUE = <T>(values: T[]): T[] => Array.from(new Set(values));
|
||||
/**
|
||||
* Build field list for Order queries
|
||||
*/
|
||||
export function buildOrderSelectFields(
|
||||
additional: string[] = []
|
||||
): string[] {
|
||||
export function buildOrderSelectFields(additional: string[] = []): string[] {
|
||||
const fields = [
|
||||
"Id",
|
||||
"AccountId",
|
||||
@ -50,9 +48,7 @@ export function buildOrderSelectFields(
|
||||
/**
|
||||
* Build field list for OrderItem queries
|
||||
*/
|
||||
export function buildOrderItemSelectFields(
|
||||
additional: string[] = []
|
||||
): string[] {
|
||||
export function buildOrderItemSelectFields(additional: string[] = []): string[] {
|
||||
const fields = [
|
||||
"Id",
|
||||
"OrderId",
|
||||
@ -69,9 +65,7 @@ export function buildOrderItemSelectFields(
|
||||
/**
|
||||
* Build field list for Product2 fields within OrderItem queries
|
||||
*/
|
||||
export function buildOrderItemProduct2Fields(
|
||||
additional: string[] = []
|
||||
): string[] {
|
||||
export function buildOrderItemProduct2Fields(additional: string[] = []): string[] {
|
||||
const fields = [
|
||||
"Id",
|
||||
"Name",
|
||||
|
||||
@ -25,16 +25,12 @@ import type {
|
||||
WhmcsValidateLoginResponse,
|
||||
WhmcsSsoResponse,
|
||||
} from "@customer-portal/domain/customer";
|
||||
import type {
|
||||
WhmcsProductListResponse,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import type { WhmcsProductListResponse } from "@customer-portal/domain/subscriptions";
|
||||
import type {
|
||||
WhmcsPaymentMethodListResponse,
|
||||
WhmcsPaymentGatewayListResponse,
|
||||
} from "@customer-portal/domain/payments";
|
||||
import type {
|
||||
WhmcsCatalogProductListResponse,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import type { WhmcsCatalogProductListResponse } from "@customer-portal/domain/catalog";
|
||||
import { WhmcsHttpClientService } from "./whmcs-http-client.service";
|
||||
import { WhmcsConfigService } from "../config/whmcs-config.service";
|
||||
import type { WhmcsRequestOptions } from "../types/connection.types";
|
||||
@ -132,7 +128,9 @@ export class WhmcsApiMethodsService {
|
||||
// PRODUCT/SUBSCRIPTION API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getClientsProducts(params: WhmcsGetClientsProductsParams): Promise<WhmcsProductListResponse> {
|
||||
async getClientsProducts(
|
||||
params: WhmcsGetClientsProductsParams
|
||||
): Promise<WhmcsProductListResponse> {
|
||||
return this.makeRequest("GetClientsProducts", params);
|
||||
}
|
||||
|
||||
@ -144,7 +142,9 @@ export class WhmcsApiMethodsService {
|
||||
// PAYMENT API METHODS
|
||||
// ==========================================
|
||||
|
||||
async getPaymentMethods(params: WhmcsGetPayMethodsParams): Promise<WhmcsPaymentMethodListResponse> {
|
||||
async getPaymentMethods(
|
||||
params: WhmcsGetPayMethodsParams
|
||||
): Promise<WhmcsPaymentMethodListResponse> {
|
||||
return this.makeRequest("GetPayMethods", params);
|
||||
}
|
||||
|
||||
|
||||
@ -7,8 +7,6 @@ import type {
|
||||
WhmcsValidateLoginParams,
|
||||
WhmcsAddClientParams,
|
||||
WhmcsClientResponse,
|
||||
} from "@customer-portal/domain/customer";
|
||||
import type {
|
||||
WhmcsAddClientResponse,
|
||||
WhmcsValidateLoginResponse,
|
||||
} from "@customer-portal/domain/customer";
|
||||
@ -38,7 +36,8 @@ export class WhmcsClientService {
|
||||
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}`);
|
||||
return {
|
||||
@ -146,7 +145,7 @@ export class WhmcsClientService {
|
||||
*/
|
||||
async addClient(clientData: WhmcsAddClientParams): Promise<{ clientId: number }> {
|
||||
try {
|
||||
const response = await this.connectionService.addClient(clientData);
|
||||
const response: WhmcsAddClientResponse = await this.connectionService.addClient(clientData);
|
||||
|
||||
this.logger.log(`Created new client: ${response.clientid}`);
|
||||
return { clientId: response.clientid };
|
||||
|
||||
@ -19,7 +19,7 @@ export class WhmcsCurrencyService implements OnModuleInit {
|
||||
// Check if WHMCS is available before trying to load currencies
|
||||
this.logger.debug("Checking WHMCS availability before loading currencies");
|
||||
const isAvailable = await this.connectionService.isAvailable();
|
||||
|
||||
|
||||
if (!isAvailable) {
|
||||
this.logger.warn("WHMCS service is not available, using fallback currency configuration");
|
||||
this.setFallbackCurrency();
|
||||
@ -49,9 +49,9 @@ export class WhmcsCurrencyService implements OnModuleInit {
|
||||
format: "1",
|
||||
rate: "1.00000",
|
||||
};
|
||||
|
||||
|
||||
this.currencies = [this.defaultCurrency];
|
||||
|
||||
|
||||
this.logger.log("Using fallback currency configuration", {
|
||||
defaultCurrency: this.defaultCurrency.code,
|
||||
});
|
||||
@ -94,13 +94,13 @@ export class WhmcsCurrencyService implements OnModuleInit {
|
||||
try {
|
||||
// The connection service returns the raw WHMCS API response data
|
||||
// (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
|
||||
if (response.result === "success" || (response.currencies && !response.error)) {
|
||||
// Parse the WHMCS response format into currency objects
|
||||
this.currencies = this.parseWhmcsCurrenciesResponse(response);
|
||||
|
||||
|
||||
if (this.currencies.length > 0) {
|
||||
// Set first currency as default (WHMCS typically returns the primary currency first)
|
||||
this.defaultCurrency = this.currencies[0];
|
||||
@ -120,7 +120,9 @@ export class WhmcsCurrencyService implements OnModuleInit {
|
||||
errorcode: response?.errorcode,
|
||||
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) {
|
||||
this.logger.error("Failed to load currencies from WHMCS", {
|
||||
@ -136,21 +138,25 @@ export class WhmcsCurrencyService implements OnModuleInit {
|
||||
*/
|
||||
private parseWhmcsCurrenciesResponse(response: WhmcsCurrenciesResponse): WhmcsCurrency[] {
|
||||
const currencies: WhmcsCurrency[] = [];
|
||||
|
||||
|
||||
// Check if response has nested currency structure
|
||||
if (response.currencies && typeof response.currencies === 'object' && 'currency' in response.currencies) {
|
||||
const currencyArray = Array.isArray(response.currencies.currency)
|
||||
? response.currencies.currency
|
||||
if (
|
||||
response.currencies &&
|
||||
typeof response.currencies === "object" &&
|
||||
"currency" in response.currencies
|
||||
) {
|
||||
const currencyArray = Array.isArray(response.currencies.currency)
|
||||
? response.currencies.currency
|
||||
: [response.currencies.currency];
|
||||
|
||||
|
||||
for (const currencyData of currencyArray) {
|
||||
const currency: WhmcsCurrency = {
|
||||
id: parseInt(String(currencyData.id)) || 0,
|
||||
code: String(currencyData.code || ''),
|
||||
prefix: String(currencyData.prefix || ''),
|
||||
suffix: String(currencyData.suffix || ''),
|
||||
format: String(currencyData.format || '1'),
|
||||
rate: String(currencyData.rate || '1.00000'),
|
||||
code: String(currencyData.code || ""),
|
||||
prefix: String(currencyData.prefix || ""),
|
||||
suffix: String(currencyData.suffix || ""),
|
||||
format: String(currencyData.format || "1"),
|
||||
rate: String(currencyData.rate || "1.00000"),
|
||||
};
|
||||
|
||||
// Validate that we have essential currency data
|
||||
@ -160,8 +166,8 @@ export class WhmcsCurrencyService implements OnModuleInit {
|
||||
}
|
||||
} else {
|
||||
// Fallback: try to parse flat format (currencies[currency][0][id], etc.)
|
||||
const currencyKeys = Object.keys(response).filter(key =>
|
||||
key.startsWith('currencies[currency][') && key.includes('][id]')
|
||||
const currencyKeys = Object.keys(response).filter(
|
||||
key => key.startsWith("currencies[currency][") && key.includes("][id]")
|
||||
);
|
||||
|
||||
// Extract currency indices
|
||||
@ -176,11 +182,11 @@ export class WhmcsCurrencyService implements OnModuleInit {
|
||||
for (const index of currencyIndices) {
|
||||
const currency: WhmcsCurrency = {
|
||||
id: parseInt(String(response[`currencies[currency][${index}][id]`])) || 0,
|
||||
code: String(response[`currencies[currency][${index}][code]`] || ''),
|
||||
prefix: String(response[`currencies[currency][${index}][prefix]`] || ''),
|
||||
suffix: String(response[`currencies[currency][${index}][suffix]`] || ''),
|
||||
format: String(response[`currencies[currency][${index}][format]`] || '1'),
|
||||
rate: String(response[`currencies[currency][${index}][rate]`] || '1.00000'),
|
||||
code: String(response[`currencies[currency][${index}][code]`] || ""),
|
||||
prefix: String(response[`currencies[currency][${index}][prefix]`] || ""),
|
||||
suffix: String(response[`currencies[currency][${index}][suffix]`] || ""),
|
||||
format: String(response[`currencies[currency][${index}][format]`] || "1"),
|
||||
rate: String(response[`currencies[currency][${index}][rate]`] || "1.00000"),
|
||||
};
|
||||
|
||||
// Validate that we have essential currency data
|
||||
|
||||
@ -1,7 +1,13 @@
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { Logger } from "nestjs-pino";
|
||||
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 { WhmcsCurrencyService } from "./whmcs-currency.service";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
@ -65,7 +71,7 @@ export class WhmcsInvoiceService {
|
||||
...(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 result = invoiceListSchema.parse(transformed as unknown);
|
||||
@ -148,7 +154,7 @@ export class WhmcsInvoiceService {
|
||||
}
|
||||
|
||||
// Fetch from WHMCS API
|
||||
const response = await this.connectionService.getInvoice(invoiceId);
|
||||
const response: WhmcsInvoiceResponse = await this.connectionService.getInvoice(invoiceId);
|
||||
|
||||
if (!response.invoiceid) {
|
||||
throw new NotFoundException(`Invoice ${invoiceId} not found`);
|
||||
@ -291,7 +297,8 @@ export class WhmcsInvoiceService {
|
||||
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") {
|
||||
throw new Error(`WHMCS invoice creation failed: ${response.message}`);
|
||||
@ -350,7 +357,8 @@ export class WhmcsInvoiceService {
|
||||
notes: params.notes,
|
||||
};
|
||||
|
||||
const response = await this.connectionService.updateInvoice(whmcsParams);
|
||||
const response: WhmcsUpdateInvoiceResponse =
|
||||
await this.connectionService.updateInvoice(whmcsParams);
|
||||
|
||||
if (response.result !== "success") {
|
||||
throw new Error(`WHMCS invoice update failed: ${response.message}`);
|
||||
@ -388,7 +396,8 @@ export class WhmcsInvoiceService {
|
||||
invoiceid: params.invoiceId,
|
||||
};
|
||||
|
||||
const response = await this.connectionService.capturePayment(whmcsParams);
|
||||
const response: WhmcsCapturePaymentResponse =
|
||||
await this.connectionService.capturePayment(whmcsParams);
|
||||
|
||||
if (response.result === "success") {
|
||||
this.logger.log(`Successfully captured payment for invoice ${params.invoiceId}`, {
|
||||
|
||||
@ -3,10 +3,7 @@ import { Logger } from "nestjs-pino";
|
||||
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
|
||||
import type {
|
||||
WhmcsOrderItem,
|
||||
WhmcsAddOrderParams,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import type { WhmcsOrderItem, WhmcsAddOrderParams } from "@customer-portal/domain/orders";
|
||||
import { Providers } from "@customer-portal/domain/orders";
|
||||
|
||||
export type { WhmcsOrderItem, WhmcsAddOrderParams };
|
||||
@ -180,7 +177,7 @@ export class WhmcsOrderService {
|
||||
/**
|
||||
* Build WHMCS AddOrder payload from our parameters
|
||||
* Following official WHMCS API documentation format
|
||||
*
|
||||
*
|
||||
* Delegates to shared mapper function from integration package
|
||||
*/
|
||||
private buildAddOrderPayload(params: WhmcsAddOrderParams): Record<string, unknown> {
|
||||
|
||||
@ -46,9 +46,11 @@ export class WhmcsPaymentService {
|
||||
}
|
||||
|
||||
// Fetch pay methods (use the documented WHMCS structure)
|
||||
const response: WhmcsPaymentMethodListResponse = await this.connectionService.getPaymentMethods({
|
||||
const params: WhmcsGetPayMethodsParams = {
|
||||
clientid: clientId,
|
||||
});
|
||||
};
|
||||
const response: WhmcsPaymentMethodListResponse =
|
||||
await this.connectionService.getPaymentMethods(params);
|
||||
|
||||
const paymentMethodsArray: WhmcsPaymentMethod[] = Array.isArray(response.paymethods)
|
||||
? response.paymethods
|
||||
@ -117,7 +119,8 @@ export class WhmcsPaymentService {
|
||||
}
|
||||
|
||||
// Fetch from WHMCS API
|
||||
const response = await this.connectionService.getPaymentGateways();
|
||||
const response: WhmcsPaymentGatewayListResponse =
|
||||
await this.connectionService.getPaymentGateways();
|
||||
|
||||
if (!response.gateways?.gateway) {
|
||||
this.logger.warn("No payment gateways found");
|
||||
@ -129,7 +132,7 @@ export class WhmcsPaymentService {
|
||||
|
||||
// Transform payment gateways
|
||||
const gateways = response.gateways.gateway
|
||||
.map(whmcsGateway => {
|
||||
.map((whmcsGateway: WhmcsPaymentGateway) => {
|
||||
try {
|
||||
return Providers.Whmcs.transformWhmcsPaymentGateway(whmcsGateway);
|
||||
} catch (error) {
|
||||
|
||||
@ -27,7 +27,7 @@ export class WhmcsSsoService {
|
||||
...(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);
|
||||
this.debugLogRedirectHost(url);
|
||||
@ -83,7 +83,7 @@ export class WhmcsSsoService {
|
||||
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)
|
||||
const url = this.resolveRedirectUrl(response.redirect_url);
|
||||
@ -104,7 +104,7 @@ export class WhmcsSsoService {
|
||||
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);
|
||||
this.debugLogRedirectHost(url);
|
||||
@ -159,7 +159,9 @@ export class WhmcsSsoService {
|
||||
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);
|
||||
this.debugLogRedirectHost(url);
|
||||
|
||||
@ -59,9 +59,7 @@ const isRecordOfStrings = (value: unknown): value is Record<string, string> =>
|
||||
!Array.isArray(value) &&
|
||||
Object.values(value).every(v => typeof v === "string");
|
||||
|
||||
const normalizeCustomFields = (
|
||||
raw: RawCustomFields
|
||||
): Record<string, string> | undefined => {
|
||||
const normalizeCustomFields = (raw: RawCustomFields): Record<string, string> | undefined => {
|
||||
if (!raw) return undefined;
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
@ -69,10 +67,7 @@ const normalizeCustomFields = (
|
||||
if (!field) return acc;
|
||||
const idKey = toOptionalString(field.id)?.trim();
|
||||
const nameKey = toOptionalString(field.name)?.trim();
|
||||
const value =
|
||||
field.value === undefined || field.value === null
|
||||
? ""
|
||||
: String(field.value);
|
||||
const value = field.value === undefined || field.value === null ? "" : String(field.value);
|
||||
|
||||
if (idKey) acc[idKey] = value;
|
||||
if (nameKey) acc[nameKey] = value;
|
||||
@ -84,15 +79,12 @@ const normalizeCustomFields = (
|
||||
|
||||
if (isRecordOfStrings(raw)) {
|
||||
const stringRecord = raw as Record<string, string>;
|
||||
const map = Object.entries(stringRecord).reduce<Record<string, string>>(
|
||||
(acc, [key, value]) => {
|
||||
const trimmedKey = key.trim();
|
||||
if (!trimmedKey) return acc;
|
||||
acc[trimmedKey] = value;
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
const map = Object.entries(stringRecord).reduce<Record<string, string>>((acc, [key, value]) => {
|
||||
const trimmedKey = key.trim();
|
||||
if (!trimmedKey) return acc;
|
||||
acc[trimmedKey] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
|
||||
return Object.keys(map).length ? map : undefined;
|
||||
}
|
||||
@ -177,9 +169,7 @@ export interface NormalizedWhmcsClient
|
||||
raw: WhmcsClient;
|
||||
}
|
||||
|
||||
export const deriveAddressFromClient = (
|
||||
client: WhmcsClient
|
||||
): Address | undefined => {
|
||||
export const deriveAddressFromClient = (client: WhmcsClient): Address | undefined => {
|
||||
const address = addressSchema.parse({
|
||||
address1: client.address1 ?? null,
|
||||
address2: client.address2 ?? null,
|
||||
@ -200,9 +190,7 @@ export const deriveAddressFromClient = (
|
||||
return hasValues ? address : undefined;
|
||||
};
|
||||
|
||||
export const normalizeWhmcsClient = (
|
||||
rawClient: WhmcsClient
|
||||
): NormalizedWhmcsClient => {
|
||||
export const normalizeWhmcsClient = (rawClient: WhmcsClient): NormalizedWhmcsClient => {
|
||||
const id = toNumber(rawClient.id);
|
||||
if (id === null) {
|
||||
throw new Error("WHMCS client ID missing or invalid.");
|
||||
@ -222,8 +210,7 @@ export const normalizeWhmcsClient = (
|
||||
users: normalizeUsers(rawClient.users),
|
||||
allowSingleSignOn: toNullableBoolean(rawClient.allowSingleSignOn) ?? null,
|
||||
email_verified: toNullableBoolean(rawClient.email_verified) ?? null,
|
||||
marketing_emails_opt_in:
|
||||
toNullableBoolean(rawClient.marketing_emails_opt_in) ?? null,
|
||||
marketing_emails_opt_in: toNullableBoolean(rawClient.marketing_emails_opt_in) ?? null,
|
||||
defaultpaymethodid: toNumber(rawClient.defaultpaymethodid),
|
||||
currency: toNumber(rawClient.currency),
|
||||
address: deriveAddressFromClient(rawClient),
|
||||
@ -254,10 +241,7 @@ export const getCustomFieldValue = (
|
||||
return undefined;
|
||||
};
|
||||
|
||||
export const buildUserProfile = (
|
||||
userAuth: UserAuth,
|
||||
client: NormalizedWhmcsClient
|
||||
): User => {
|
||||
export const buildUserProfile = (userAuth: UserAuth, client: NormalizedWhmcsClient): User => {
|
||||
const payload = {
|
||||
id: userAuth.id,
|
||||
email: userAuth.email,
|
||||
@ -272,10 +256,7 @@ export const buildUserProfile = (
|
||||
fullname: client.fullname ?? null,
|
||||
companyname: client.companyname ?? null,
|
||||
phonenumber:
|
||||
client.phonenumberformatted ??
|
||||
client.phonenumber ??
|
||||
client.telephoneNumber ??
|
||||
null,
|
||||
client.phonenumberformatted ?? client.phonenumber ?? client.telephoneNumber ?? null,
|
||||
language: client.language ?? null,
|
||||
currency_code: client.currency_code ?? null,
|
||||
address: client.address ?? undefined,
|
||||
@ -284,6 +265,4 @@ export const buildUserProfile = (
|
||||
return userSchema.parse(payload);
|
||||
};
|
||||
|
||||
export const getNumericClientId = (
|
||||
client: NormalizedWhmcsClient
|
||||
): number => client.id;
|
||||
export const getNumericClientId = (client: NormalizedWhmcsClient): number => client.id;
|
||||
|
||||
@ -1,4 +1,3 @@
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import type { Invoice, InvoiceList } from "@customer-portal/domain/billing";
|
||||
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 { WhmcsSsoService } from "./services/whmcs-sso.service";
|
||||
import { WhmcsOrderService } from "./services/whmcs-order.service";
|
||||
import type {
|
||||
WhmcsAddClientParams,
|
||||
WhmcsClientResponse,
|
||||
} from "@customer-portal/domain/customer";
|
||||
import type { WhmcsAddClientParams, WhmcsClientResponse } from "@customer-portal/domain/customer";
|
||||
import type { WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions";
|
||||
import type {
|
||||
WhmcsProductListResponse,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import type {
|
||||
WhmcsCatalogProductListResponse,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import type { WhmcsProductListResponse } from "@customer-portal/domain/subscriptions";
|
||||
import type { WhmcsCatalogProductListResponse } from "@customer-portal/domain/catalog";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { deriveAddressFromClient, type NormalizedWhmcsClient } from "./utils/whmcs-client.utils";
|
||||
|
||||
@ -282,7 +274,9 @@ export class WhmcsService {
|
||||
return this.connectionService.getSystemInfo();
|
||||
}
|
||||
|
||||
async getClientsProducts(params: WhmcsGetClientsProductsParams): Promise<WhmcsProductListResponse> {
|
||||
async getClientsProducts(
|
||||
params: WhmcsGetClientsProductsParams
|
||||
): Promise<WhmcsProductListResponse> {
|
||||
return this.connectionService.getClientsProducts(params);
|
||||
}
|
||||
|
||||
|
||||
@ -17,14 +17,6 @@ import {
|
||||
type SetPasswordRequest,
|
||||
type ChangePasswordRequest,
|
||||
type SsoLinkResponse,
|
||||
type CheckPasswordNeededResponse,
|
||||
signupRequestSchema,
|
||||
validateSignupRequestSchema,
|
||||
linkWhmcsRequestSchema,
|
||||
setPasswordRequestSchema,
|
||||
updateProfileRequestSchema,
|
||||
updateAddressRequestSchema,
|
||||
changePasswordRequestSchema,
|
||||
} from "@customer-portal/domain/auth";
|
||||
|
||||
import type { User as PrismaUser } from "@prisma/client";
|
||||
@ -326,10 +318,7 @@ export class AuthFacade {
|
||||
/**
|
||||
* Create SSO link to WHMCS for general access
|
||||
*/
|
||||
async createSsoLink(
|
||||
userId: string,
|
||||
destination?: string
|
||||
): Promise<SsoLinkResponse> {
|
||||
async createSsoLink(userId: string, destination?: string): Promise<SsoLinkResponse> {
|
||||
try {
|
||||
// Production-safe logging - no sensitive data
|
||||
this.logger.log("Creating SSO link request");
|
||||
|
||||
@ -11,12 +11,10 @@ import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { AuthTokenService } from "../../token/token.service";
|
||||
import { AuthRateLimitService } from "../../rate-limiting/auth-rate-limit.service";
|
||||
import {
|
||||
type AuthTokens,
|
||||
type PasswordChangeResult,
|
||||
type ChangePasswordRequest,
|
||||
changePasswordRequestSchema,
|
||||
} from "@customer-portal/domain/auth";
|
||||
import type { User } from "@customer-portal/domain/customer";
|
||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
||||
|
||||
@Injectable()
|
||||
|
||||
@ -23,9 +23,7 @@ import {
|
||||
type SignupRequest,
|
||||
type SignupResult,
|
||||
type ValidateSignupRequest,
|
||||
type AuthTokens,
|
||||
} from "@customer-portal/domain/auth";
|
||||
import type { User } from "@customer-portal/domain/customer";
|
||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
||||
import type { User as PrismaUser } from "@prisma/client";
|
||||
|
||||
@ -137,7 +135,7 @@ export class SignupWorkflowService {
|
||||
if (request) {
|
||||
await this.authRateLimitService.consumeSignupAttempt(request);
|
||||
}
|
||||
|
||||
|
||||
// Validate signup data using schema (throws on validation error)
|
||||
signupRequestSchema.parse(signupData);
|
||||
|
||||
@ -474,5 +472,4 @@ export class SignupWorkflowService {
|
||||
result.messages.push("All checks passed. Ready to create your account.");
|
||||
return result;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -13,7 +13,10 @@ import { SalesforceService } from "@bff/integrations/salesforce/salesforce.servi
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
||||
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
|
||||
|
||||
@Injectable()
|
||||
|
||||
@ -255,7 +255,10 @@ export class AuthController {
|
||||
@Post("reset-password")
|
||||
@HttpCode(200)
|
||||
@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);
|
||||
|
||||
// Clear auth cookies after password reset to force re-login
|
||||
|
||||
@ -30,11 +30,9 @@ export class BaseCatalogService {
|
||||
) {
|
||||
const portalPricebook = this.configService.get<string>("PORTAL_PRICEBOOK_ID")!;
|
||||
this.portalPriceBookId = assertSalesforceId(portalPricebook, "PORTAL_PRICEBOOK_ID");
|
||||
const portalCategory = this.configService.get<string>("PRODUCT_PORTAL_CATEGORY_FIELD") ?? "Product2Categories1__c";
|
||||
this.portalCategoryField = assertSoqlFieldName(
|
||||
portalCategory,
|
||||
"PRODUCT_PORTAL_CATEGORY_FIELD"
|
||||
);
|
||||
const portalCategory =
|
||||
this.configService.get<string>("PRODUCT_PORTAL_CATEGORY_FIELD") ?? "Product2Categories1__c";
|
||||
this.portalCategoryField = assertSoqlFieldName(portalCategory, "PRODUCT_PORTAL_CATEGORY_FIELD");
|
||||
}
|
||||
|
||||
protected async executeQuery<TRecord extends SalesforceProduct2WithPricebookEntries>(
|
||||
|
||||
@ -179,5 +179,4 @@ export class InternetCatalogService extends BaseCatalogService {
|
||||
// e.g., eligibility "Home 1G" matches plan.internetOfferingType "Home 1G"
|
||||
return plan.internetOfferingType === eligibility;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -3,7 +3,10 @@ import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.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";
|
||||
|
||||
@Injectable()
|
||||
@ -16,10 +19,7 @@ export class VpnCatalogService extends BaseCatalogService {
|
||||
super(sf, configService, logger);
|
||||
}
|
||||
async getPlans(): Promise<VpnCatalogProduct[]> {
|
||||
const soql = this.buildCatalogServiceQuery("VPN", [
|
||||
"VPN_Region__c",
|
||||
"Catalog_Order__c",
|
||||
]);
|
||||
const soql = this.buildCatalogServiceQuery("VPN", ["VPN_Region__c", "Catalog_Order__c"]);
|
||||
const records = await this.executeQuery<SalesforceProduct2WithPricebookEntries>(
|
||||
soql,
|
||||
"VPN Plans"
|
||||
|
||||
@ -6,6 +6,7 @@ import { WhmcsModule } from "../../integrations/whmcs/whmcs.module";
|
||||
@Module({
|
||||
imports: [WhmcsModule],
|
||||
controllers: [CurrencyController],
|
||||
providers: [],
|
||||
providers: [WhmcsCurrencyService],
|
||||
exports: [WhmcsCurrencyService],
|
||||
})
|
||||
export class CurrencyModule {}
|
||||
|
||||
@ -53,7 +53,6 @@ export class MappingsService {
|
||||
throw error;
|
||||
}
|
||||
|
||||
const warnings = checkMappingCompleteness(validatedRequest);
|
||||
const sanitizedRequest = validatedRequest;
|
||||
|
||||
const [byUser, byWhmcs, bySf] = await Promise.all([
|
||||
@ -68,18 +67,15 @@ export class MappingsService {
|
||||
: Promise.resolve(null),
|
||||
]);
|
||||
|
||||
if (byUser) {
|
||||
throw new ConflictException(`User ${sanitizedRequest.userId} already has a mapping`);
|
||||
}
|
||||
if (byWhmcs) {
|
||||
throw new ConflictException(
|
||||
`WHMCS client ${sanitizedRequest.whmcsClientId} is already mapped to user ${byWhmcs.userId}`
|
||||
);
|
||||
}
|
||||
if (bySf) {
|
||||
this.logger.warn(
|
||||
`Salesforce account ${sanitizedRequest.sfAccountId} is already mapped to user ${bySf.userId}`
|
||||
);
|
||||
const existingMappings = [byUser, byWhmcs, bySf]
|
||||
.filter((mapping): mapping is Prisma.IdMapping => mapping !== null)
|
||||
.map(mapPrismaMappingToDomain);
|
||||
|
||||
const conflictCheck = validateNoConflicts(sanitizedRequest, existingMappings);
|
||||
const warnings = [...checkMappingCompleteness(sanitizedRequest), ...conflictCheck.warnings];
|
||||
|
||||
if (!conflictCheck.isValid) {
|
||||
throw new ConflictException(conflictCheck.errors.join("; "));
|
||||
}
|
||||
|
||||
let created;
|
||||
|
||||
@ -9,7 +9,4 @@ export * from "./services/invoice-retrieval.service";
|
||||
export * from "./services/invoice-health.service";
|
||||
|
||||
// Export monitoring types (infrastructure concerns)
|
||||
export type {
|
||||
InvoiceHealthStatus,
|
||||
InvoiceServiceStats,
|
||||
} from "./types/invoice-monitoring.types";
|
||||
export type { InvoiceHealthStatus, InvoiceServiceStats } from "./types/invoice-monitoring.types";
|
||||
|
||||
@ -16,14 +16,23 @@ import { MappingsService } from "@bff/modules/id-mappings/mappings.service";
|
||||
import { ZodValidationPipe } from "@bff/core/validation";
|
||||
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 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
|
||||
*
|
||||
*
|
||||
* All request validation is handled by Zod schemas via ZodValidationPipe.
|
||||
* Business logic is delegated to service layer.
|
||||
*/
|
||||
@ -88,7 +97,7 @@ export class InvoicesController {
|
||||
): Subscription[] {
|
||||
// Validate using domain schema
|
||||
invoiceSchema.shape.id.parse(invoiceId);
|
||||
|
||||
|
||||
// This functionality has been moved to WHMCS directly
|
||||
// For now, return empty array as subscriptions are managed in WHMCS
|
||||
return [];
|
||||
|
||||
@ -9,18 +9,14 @@ import { InvoiceHealthService } from "./services/invoice-health.service";
|
||||
|
||||
/**
|
||||
* Invoice Module
|
||||
*
|
||||
*
|
||||
* Validation is now handled by Zod schemas via ZodValidationPipe in controller.
|
||||
* No separate validator service needed.
|
||||
*/
|
||||
@Module({
|
||||
imports: [WhmcsModule, MappingsModule],
|
||||
controllers: [InvoicesController],
|
||||
providers: [
|
||||
InvoicesOrchestratorService,
|
||||
InvoiceRetrievalService,
|
||||
InvoiceHealthService,
|
||||
],
|
||||
providers: [InvoicesOrchestratorService, InvoiceRetrievalService, InvoiceHealthService],
|
||||
exports: [InvoicesOrchestratorService],
|
||||
})
|
||||
export class InvoicesModule {}
|
||||
|
||||
@ -25,7 +25,7 @@ interface UserMappingInfo {
|
||||
|
||||
/**
|
||||
* Service responsible for retrieving invoices from WHMCS
|
||||
*
|
||||
*
|
||||
* Validation is handled by Zod schemas at the entry point (controller).
|
||||
* This service focuses on business logic and data fetching.
|
||||
*/
|
||||
@ -146,21 +146,30 @@ export class InvoiceRetrievalService {
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
@ -190,7 +199,7 @@ export class InvoiceRetrievalService {
|
||||
private async getUserMapping(userId: string): Promise<UserMappingInfo> {
|
||||
// Validate userId is a valid UUID
|
||||
validateUuidV4OrThrow(userId);
|
||||
|
||||
|
||||
const mapping = await this.mappingsService.findByUserId(userId);
|
||||
|
||||
if (!mapping?.whmcsClientId) {
|
||||
|
||||
@ -10,10 +10,7 @@ import {
|
||||
} from "@customer-portal/domain/billing";
|
||||
import { InvoiceRetrievalService } from "./invoice-retrieval.service";
|
||||
import { InvoiceHealthService } from "./invoice-health.service";
|
||||
import type {
|
||||
InvoiceHealthStatus,
|
||||
InvoiceServiceStats,
|
||||
} from "../types/invoice-monitoring.types";
|
||||
import type { InvoiceHealthStatus, InvoiceServiceStats } from "../types/invoice-monitoring.types";
|
||||
|
||||
/**
|
||||
* Main orchestrator service for invoice operations
|
||||
@ -86,21 +83,30 @@ export class InvoicesOrchestratorService {
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
/**
|
||||
* BFF Invoice Monitoring Types
|
||||
*
|
||||
*
|
||||
* Infrastructure types for monitoring, health checks, and statistics.
|
||||
* These are BFF-specific and do not belong in the domain layer.
|
||||
*/
|
||||
@ -24,4 +24,3 @@ export interface InvoiceHealthStatus {
|
||||
timestamp: string;
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -28,7 +28,7 @@ import { ProvisioningProcessor } from "./queue/provisioning.processor";
|
||||
providers: [
|
||||
// Shared services
|
||||
PaymentValidatorService,
|
||||
|
||||
|
||||
// Order creation services (modular)
|
||||
OrderValidator,
|
||||
OrderBuilder,
|
||||
|
||||
@ -32,7 +32,7 @@ export class ProvisioningProcessor extends WorkerHost {
|
||||
});
|
||||
|
||||
// Guard: Only process if Salesforce Order is currently 'Activating'
|
||||
|
||||
|
||||
const order = await this.salesforceService.getOrder(sfOrderId);
|
||||
const status = order?.Activation_Status__c ?? "";
|
||||
const lastErrorCode = order?.Activation_Error_Code__c ?? "";
|
||||
|
||||
@ -74,10 +74,7 @@ export class OrderBuilder {
|
||||
assignIfString(orderFields, "Access_Mode__c", config.accessMode);
|
||||
}
|
||||
|
||||
private addSimFields(
|
||||
orderFields: Record<string, unknown>,
|
||||
body: OrderBusinessValidation
|
||||
): void {
|
||||
private addSimFields(orderFields: Record<string, unknown>, body: OrderBusinessValidation): void {
|
||||
const config = body.configurations || {};
|
||||
assignIfString(orderFields, "SIM_Type__c", config.simType);
|
||||
assignIfString(orderFields, "EID__c", config.eid);
|
||||
@ -91,7 +88,11 @@ export class OrderBuilder {
|
||||
assignIfString(orderFields, "Porting_Last_Name__c", config.portingLastName);
|
||||
assignIfString(orderFields, "Porting_First_Name__c", config.portingFirstName);
|
||||
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_Date_Of_Birth__c", config.portingDateOfBirth);
|
||||
}
|
||||
@ -124,9 +125,12 @@ export class OrderBuilder {
|
||||
|
||||
orderFields.Billing_Street__c = fullStreet;
|
||||
orderFields.Billing_City__c = typeof addressToUse?.city === "string" ? addressToUse.city : "";
|
||||
orderFields.Billing_State__c = typeof addressToUse?.state === "string" ? addressToUse.state : "";
|
||||
orderFields.Billing_Postal_Code__c = typeof addressToUse?.postcode === "string" ? addressToUse.postcode : "";
|
||||
orderFields.Billing_Country__c = typeof addressToUse?.country === "string" ? addressToUse.country : "";
|
||||
orderFields.Billing_State__c =
|
||||
typeof addressToUse?.state === "string" ? addressToUse.state : "";
|
||||
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;
|
||||
|
||||
if (addressChanged) {
|
||||
|
||||
@ -5,7 +5,7 @@ import type { OrderFulfillmentErrorCode } from "@customer-portal/domain/orders";
|
||||
/**
|
||||
* Centralized error code determination and error handling for order fulfillment
|
||||
* Eliminates duplicate error code logic across services
|
||||
*
|
||||
*
|
||||
* Note: Error codes are now defined in @customer-portal/domain/orders as business constants
|
||||
*/
|
||||
@Injectable()
|
||||
|
||||
@ -14,13 +14,7 @@ import { OrderFulfillmentErrorService } from "./order-fulfillment-error.service"
|
||||
import { SimFulfillmentService } from "./sim-fulfillment.service";
|
||||
import { DistributedTransactionService } from "@bff/core/database/services/distributed-transaction.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import {
|
||||
type OrderSummary,
|
||||
type OrderDetails,
|
||||
type SalesforceOrderRecord,
|
||||
type SalesforceOrderItemRecord,
|
||||
Providers as OrderProviders,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import { type OrderDetails, Providers as OrderProviders } from "@customer-portal/domain/orders";
|
||||
|
||||
type WhmcsOrderItemMappingResult = ReturnType<typeof OrderProviders.Whmcs.mapOrderToWhmcsItems>;
|
||||
|
||||
@ -138,14 +132,12 @@ export class OrderFulfillmentOrchestrator {
|
||||
id: "sf_status_update",
|
||||
description: "Update Salesforce order status to Activating",
|
||||
execute: async () => {
|
||||
|
||||
return await this.salesforceService.updateOrder({
|
||||
Id: sfOrderId,
|
||||
Activation_Status__c: "Activating",
|
||||
});
|
||||
},
|
||||
rollback: async () => {
|
||||
|
||||
await this.salesforceService.updateOrder({
|
||||
Id: sfOrderId,
|
||||
Activation_Status__c: "Failed",
|
||||
@ -169,13 +161,13 @@ export class OrderFulfillmentOrchestrator {
|
||||
// Use domain mapper directly - single transformation!
|
||||
const result = OrderProviders.Whmcs.mapOrderToWhmcsItems(context.orderDetails);
|
||||
mappingResult = result;
|
||||
|
||||
|
||||
this.logger.log("OrderItems mapped to WHMCS", {
|
||||
totalItems: result.summary.totalItems,
|
||||
serviceItems: result.summary.serviceItems,
|
||||
activationItems: result.summary.activationItems,
|
||||
});
|
||||
|
||||
|
||||
return Promise.resolve(result);
|
||||
},
|
||||
critical: true,
|
||||
@ -281,8 +273,6 @@ export class OrderFulfillmentOrchestrator {
|
||||
id: "sf_success_update",
|
||||
description: "Update Salesforce with success",
|
||||
execute: async () => {
|
||||
|
||||
|
||||
return await this.salesforceService.updateOrder({
|
||||
Id: sfOrderId,
|
||||
Status: "Completed",
|
||||
@ -291,7 +281,6 @@ export class OrderFulfillmentOrchestrator {
|
||||
});
|
||||
},
|
||||
rollback: async () => {
|
||||
|
||||
await this.salesforceService.updateOrder({
|
||||
Id: sfOrderId,
|
||||
Activation_Status__c: "Failed",
|
||||
@ -330,7 +319,6 @@ export class OrderFulfillmentOrchestrator {
|
||||
return context;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Initialize fulfillment steps
|
||||
*/
|
||||
@ -353,7 +341,6 @@ export class OrderFulfillmentOrchestrator {
|
||||
return steps;
|
||||
}
|
||||
|
||||
|
||||
private extractConfigurations(value: unknown): Record<string, unknown> {
|
||||
if (value && typeof value === "object") {
|
||||
return value as Record<string, unknown>;
|
||||
@ -361,7 +348,6 @@ export class OrderFulfillmentOrchestrator {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Handle fulfillment errors and update Salesforce
|
||||
*/
|
||||
@ -371,7 +357,6 @@ export class OrderFulfillmentOrchestrator {
|
||||
): Promise<void> {
|
||||
const errorCode = this.orderFulfillmentErrorService.determineErrorCode(error);
|
||||
const userMessage = error.message;
|
||||
|
||||
|
||||
this.logger.error("Fulfillment orchestration failed", {
|
||||
sfOrderId: context.sfOrderId,
|
||||
|
||||
@ -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 { z } from "zod";
|
||||
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 { sfOrderIdParamSchema } from "@customer-portal/domain/orders";
|
||||
import { PaymentValidatorService } from "./payment-validator.service";
|
||||
type OrderStringFieldKey = "activationStatus";
|
||||
|
||||
// Schema for validating Salesforce Account ID
|
||||
const salesforceAccountIdSchema = z.string().min(1, "Salesforce AccountId is required");
|
||||
@ -52,7 +51,7 @@ export class OrderFulfillmentValidator {
|
||||
const sfOrder = await this.validateSalesforceOrder(sfOrderId);
|
||||
|
||||
// 2. Check if already provisioned (idempotency)
|
||||
|
||||
|
||||
const existingWhmcsOrderId = sfOrder.WHMCS_Order_ID__c;
|
||||
if (existingWhmcsOrderId) {
|
||||
this.logger.log("Order already provisioned", {
|
||||
@ -115,7 +114,6 @@ export class OrderFulfillmentValidator {
|
||||
throw new BadRequestException(`Cannot provision cancelled order ${sfOrderId}`);
|
||||
}
|
||||
|
||||
|
||||
this.logger.log("Salesforce order validated", {
|
||||
sfOrderId,
|
||||
status: order.Status,
|
||||
|
||||
@ -4,10 +4,7 @@ import { SalesforceOrderService } from "@bff/integrations/salesforce/services/sa
|
||||
import { OrderValidator } from "./order-validator.service";
|
||||
import { OrderBuilder } from "./order-builder.service";
|
||||
import { OrderItemBuilder } from "./order-item-builder.service";
|
||||
import {
|
||||
type OrderDetails,
|
||||
type OrderSummary,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import { type OrderDetails, type OrderSummary } from "@customer-portal/domain/orders";
|
||||
import { assertSalesforceId } from "@bff/integrations/salesforce/utils/soql.util";
|
||||
|
||||
type OrderDetailsResponse = OrderDetails;
|
||||
@ -101,7 +98,7 @@ export class OrderOrchestrator {
|
||||
const sfAccountId = userMapping.sfAccountId
|
||||
? assertSalesforceId(userMapping.sfAccountId, "sfAccountId")
|
||||
: undefined;
|
||||
|
||||
|
||||
if (!sfAccountId) {
|
||||
this.logger.warn({ userId }, "User mapping missing Salesforce account ID");
|
||||
return [];
|
||||
|
||||
@ -2,7 +2,6 @@ import { Inject, Injectable, NotFoundException } from "@nestjs/common";
|
||||
import { ConfigService } from "@nestjs/config";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { SalesforceConnection } from "@bff/integrations/salesforce/services/salesforce-connection.service";
|
||||
import { z } from "zod";
|
||||
import type {
|
||||
SalesforceProduct2Record,
|
||||
SalesforcePricebookEntryRecord,
|
||||
@ -78,7 +77,6 @@ export class OrderPricebookService {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
|
||||
const meta = new Map<string, PricebookProductMeta>();
|
||||
|
||||
for (let i = 0; i < uniqueSkus.length; i += this.chunkSize) {
|
||||
|
||||
@ -18,7 +18,7 @@ import { PaymentValidatorService } from "./payment-validator.service";
|
||||
|
||||
/**
|
||||
* Handles all order validation logic - both format and business rules
|
||||
*
|
||||
*
|
||||
* 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.)
|
||||
*/
|
||||
|
||||
@ -5,7 +5,7 @@ import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
|
||||
/**
|
||||
* Shared Payment Validation Service
|
||||
*
|
||||
*
|
||||
* Provides consistent payment method validation across order workflows.
|
||||
* Used by both OrderValidator and OrderFulfillmentValidator.
|
||||
*/
|
||||
@ -18,35 +18,34 @@ export class PaymentValidatorService {
|
||||
|
||||
/**
|
||||
* Validate that a payment method exists for the WHMCS client
|
||||
*
|
||||
*
|
||||
* @param userId - User ID for logging purposes
|
||||
* @param whmcsClientId - WHMCS client ID to check
|
||||
* @throws BadRequestException if no payment method exists or verification fails
|
||||
*/
|
||||
async validatePaymentMethodExists(
|
||||
userId: string,
|
||||
whmcsClientId: number
|
||||
): Promise<void> {
|
||||
async validatePaymentMethodExists(userId: string, whmcsClientId: number): Promise<void> {
|
||||
try {
|
||||
const pay = await this.whmcs.getPaymentMethods({ clientid: whmcsClientId });
|
||||
const paymentMethods = Array.isArray(pay.paymethods) ? pay.paymethods : [];
|
||||
|
||||
|
||||
if (paymentMethods.length === 0) {
|
||||
this.logger.warn({ userId, whmcsClientId }, "No payment method on file");
|
||||
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) {
|
||||
// Re-throw BadRequestException as-is
|
||||
if (e instanceof BadRequestException) {
|
||||
throw e;
|
||||
}
|
||||
|
||||
|
||||
const err = getErrorMessage(e);
|
||||
this.logger.error({ err, userId, whmcsClientId }, "Payment method verification failed");
|
||||
throw new BadRequestException("Unable to verify payment method. Please try again later.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -120,10 +120,7 @@ export class SimManagementService {
|
||||
/**
|
||||
* Get comprehensive SIM information (details + usage combined)
|
||||
*/
|
||||
async getSimInfo(
|
||||
userId: string,
|
||||
subscriptionId: number
|
||||
): Promise<SimInfo> {
|
||||
async getSimInfo(userId: string, subscriptionId: number): Promise<SimInfo> {
|
||||
return this.simOrchestrator.getSimInfo(userId, subscriptionId);
|
||||
}
|
||||
|
||||
|
||||
@ -111,10 +111,7 @@ export class SimOrchestratorService {
|
||||
/**
|
||||
* Get comprehensive SIM information (details + usage combined)
|
||||
*/
|
||||
async getSimInfo(
|
||||
userId: string,
|
||||
subscriptionId: number
|
||||
): Promise<SimInfo> {
|
||||
async getSimInfo(userId: string, subscriptionId: number): Promise<SimInfo> {
|
||||
try {
|
||||
const [details, usage] = await Promise.all([
|
||||
this.getSimDetails(userId, subscriptionId),
|
||||
|
||||
@ -3,7 +3,11 @@ import { Logger } from "nestjs-pino";
|
||||
import { SubscriptionsService } from "../../subscriptions.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
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()
|
||||
export class SimValidationService {
|
||||
@ -14,7 +18,7 @@ export class SimValidationService {
|
||||
|
||||
/**
|
||||
* Check if a subscription is a SIM service and extract account identifier
|
||||
*
|
||||
*
|
||||
* Uses domain validation functions for business logic.
|
||||
*/
|
||||
async validateSimSubscription(
|
||||
|
||||
@ -139,7 +139,10 @@ export class SubscriptionsService {
|
||||
/**
|
||||
* Get subscriptions by status
|
||||
*/
|
||||
async getSubscriptionsByStatus(userId: string, status: SubscriptionStatus): Promise<Subscription[]> {
|
||||
async getSubscriptionsByStatus(
|
||||
userId: string,
|
||||
status: SubscriptionStatus
|
||||
): Promise<Subscription[]> {
|
||||
try {
|
||||
const normalizedStatus = subscriptionStatusSchema.parse(status);
|
||||
const subscriptionList = await this.getSubscriptions(userId, { status: normalizedStatus });
|
||||
|
||||
@ -63,7 +63,7 @@ export class UsersController {
|
||||
* PATCH /me - Update customer profile (can update profile fields and/or address)
|
||||
* All fields optional - only send what needs to be updated
|
||||
* Updates stored in WHMCS (single source of truth)
|
||||
*
|
||||
*
|
||||
* Examples:
|
||||
* - Update name only: { firstname: "John", lastname: "Doe" }
|
||||
* - Update address only: { address1: "123 Main St", city: "Tokyo" }
|
||||
@ -71,7 +71,10 @@ export class UsersController {
|
||||
*/
|
||||
@Patch()
|
||||
@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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,13 +25,7 @@ import { buildUserProfile } from "@bff/integrations/whmcs/utils/whmcs-client.uti
|
||||
|
||||
// Use a subset of PrismaUser for auth-related updates only
|
||||
type UserUpdateData = Partial<
|
||||
Pick<
|
||||
PrismaUser,
|
||||
| "passwordHash"
|
||||
| "failedLoginAttempts"
|
||||
| "lastLoginAt"
|
||||
| "lockedUntil"
|
||||
>
|
||||
Pick<PrismaUser, "passwordHash" | "failedLoginAttempts" | "lastLoginAt" | "lockedUntil">
|
||||
>;
|
||||
|
||||
@Injectable()
|
||||
@ -54,7 +48,7 @@ export class UsersService {
|
||||
const user = await this.prisma.user.findUnique({
|
||||
where: { email: validEmail },
|
||||
});
|
||||
|
||||
|
||||
if (!user) return null;
|
||||
|
||||
// Return full profile with WHMCS data
|
||||
@ -134,10 +128,10 @@ export class UsersService {
|
||||
try {
|
||||
// Get WHMCS client data (source of truth for profile)
|
||||
const whmcsClient = await this.whmcsService.getClientDetails(mapping.whmcsClientId);
|
||||
|
||||
|
||||
// Map Prisma user to UserAuth
|
||||
const userAuth = CustomerProviders.Portal.mapPrismaUserToUserAuth(user);
|
||||
|
||||
|
||||
return buildUserProfile(userAuth, whmcsClient);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to fetch client profile from WHMCS", {
|
||||
@ -223,7 +217,7 @@ export class UsersService {
|
||||
const createdUser = await this.prisma.user.create({
|
||||
data: normalizedData,
|
||||
});
|
||||
|
||||
|
||||
// Return full profile from WHMCS
|
||||
return this.getProfile(createdUser.id);
|
||||
} catch (error) {
|
||||
@ -274,15 +268,18 @@ export class UsersService {
|
||||
|
||||
// Update in WHMCS (all fields optional)
|
||||
await this.whmcsService.updateClient(mapping.whmcsClientId, parsed);
|
||||
|
||||
|
||||
this.logger.log({ userId: validId }, "Successfully updated customer profile in WHMCS");
|
||||
|
||||
// Return fresh profile
|
||||
return this.getProfile(validId);
|
||||
} catch (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")) {
|
||||
throw new BadRequestException(msg.replace("WHMCS API Error: ", ""));
|
||||
}
|
||||
@ -321,7 +318,7 @@ export class UsersService {
|
||||
const mapping = await this.mappingsService.findByUserId(userId);
|
||||
if (!mapping?.whmcsClientId) {
|
||||
this.logger.warn(`No WHMCS mapping found for user ${userId}`);
|
||||
|
||||
|
||||
// Get currency from WHMCS profile if available
|
||||
let currency = "JPY"; // Default
|
||||
try {
|
||||
@ -333,7 +330,7 @@ export class UsersService {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
const summary: DashboardSummary = {
|
||||
stats: {
|
||||
activeSubscriptions: 0,
|
||||
@ -505,7 +502,8 @@ export class UsersService {
|
||||
productName: subscription.productName,
|
||||
status: subscription.status,
|
||||
} as Record<string, unknown>;
|
||||
if (subscription.registrationDate) metadata.registrationDate = subscription.registrationDate;
|
||||
if (subscription.registrationDate)
|
||||
metadata.registrationDate = subscription.registrationDate;
|
||||
activities.push({
|
||||
id: `service-activated-${subscription.id}`,
|
||||
type: "service_activated",
|
||||
@ -561,5 +559,4 @@ export class UsersService {
|
||||
throw new BadRequestException("Unable to retrieve dashboard summary");
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@ -124,9 +124,7 @@ const nextConfig = {
|
||||
};
|
||||
const preferredExtensions = [".ts", ".tsx", ".mts", ".cts"];
|
||||
const existingExtensions = config.resolve.extensions || [];
|
||||
config.resolve.extensions = [
|
||||
...new Set([...preferredExtensions, ...existingExtensions]),
|
||||
];
|
||||
config.resolve.extensions = [...new Set([...preferredExtensions, ...existingExtensions])];
|
||||
config.resolve.extensionAlias = {
|
||||
...(config.resolve.extensionAlias || {}),
|
||||
".js": [".ts", ".tsx", ".js"],
|
||||
|
||||
@ -68,6 +68,7 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
|
||||
|
||||
if (props.as === "a") {
|
||||
const { className, variant, size, as: _as, href, ...anchorProps } = rest as ButtonAsAnchorProps;
|
||||
void _as;
|
||||
return (
|
||||
<a
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
@ -97,6 +98,7 @@ const Button = forwardRef<HTMLButtonElement | HTMLAnchorElement, ButtonProps>((p
|
||||
disabled,
|
||||
...buttonProps
|
||||
} = rest as ButtonAsButtonProps;
|
||||
void _as;
|
||||
return (
|
||||
<button
|
||||
className={cn(buttonVariants({ variant, size, className }))}
|
||||
|
||||
@ -23,8 +23,9 @@ export function AsyncBlock({
|
||||
if (isLoading) {
|
||||
if (variant === "page") {
|
||||
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">
|
||||
{loadingText ? <p className="text-sm text-gray-500">{loadingText}</p> : null}
|
||||
<div className="flex items-center gap-3">
|
||||
<Skeleton className="h-8 w-8 rounded-full" />
|
||||
<div className="space-y-2">
|
||||
@ -45,7 +46,8 @@ export function AsyncBlock({
|
||||
);
|
||||
}
|
||||
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-3 w-3/5" />
|
||||
<Skeleton className="h-3 w-2/5" />
|
||||
|
||||
@ -41,6 +41,8 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
|
||||
const errorId = error ? `${id}-error` : undefined;
|
||||
const helperTextId = helperText ? `${id}-helper` : undefined;
|
||||
|
||||
const { className: inputPropsClassName, ...restInputProps } = inputProps;
|
||||
|
||||
return (
|
||||
<div className={cn("space-y-1", containerClassName)}>
|
||||
{label && (
|
||||
@ -80,12 +82,9 @@ const FormField = forwardRef<HTMLInputElement, FormFieldProps>(
|
||||
className={cn(
|
||||
error && "border-red-500 focus-visible:ring-red-500 focus-visible:ring-offset-2",
|
||||
inputClassName,
|
||||
inputProps.className
|
||||
inputPropsClassName
|
||||
)}
|
||||
{...(() => {
|
||||
const { className, ...rest } = inputProps;
|
||||
return rest;
|
||||
})()}
|
||||
{...restInputProps}
|
||||
/>
|
||||
)}
|
||||
{error && <ErrorMessage id={errorId}>{error}</ErrorMessage>}
|
||||
|
||||
@ -26,7 +26,10 @@ export function AppShell({ children }: AppShellProps) {
|
||||
const pathname = usePathname();
|
||||
const router = useRouter();
|
||||
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
|
||||
const [expandedItems, setExpandedItems] = useState<string[]>([]);
|
||||
@ -88,7 +91,7 @@ export function AppShell({ children }: AppShellProps) {
|
||||
// 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
|
||||
useEffect(() => {
|
||||
|
||||
@ -1,5 +1,4 @@
|
||||
import type { Subscription } from "@customer-portal/domain/subscriptions";
|
||||
import type { ReactNode } from "react";
|
||||
import {
|
||||
HomeIcon,
|
||||
CreditCardIcon,
|
||||
|
||||
@ -27,8 +27,9 @@ export function AddressCard({
|
||||
onSave,
|
||||
onAddressChange,
|
||||
}: AddressCardProps) {
|
||||
const countryLabel =
|
||||
address.country ? getCountryName(address.country) ?? address.country : null;
|
||||
const countryLabel = address.country
|
||||
? (getCountryName(address.country) ?? address.country)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<SubCard>
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
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 {
|
||||
MapPinIcon,
|
||||
@ -83,7 +83,7 @@ export default function ProfileContainer() {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
}, [user?.id]);
|
||||
}, [address, profile, user?.id]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
@ -237,7 +237,9 @@ export default function ProfileContainer() {
|
||||
/>
|
||||
) : (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -24,7 +24,7 @@ interface LoginFormProps {
|
||||
|
||||
/**
|
||||
* Frontend form schema - extends domain loginRequestSchema with UI-specific fields
|
||||
*
|
||||
*
|
||||
* Single source of truth: Domain layer (loginRequestSchema) defines validation rules
|
||||
* Frontend only adds: rememberMe field for UI state
|
||||
*/
|
||||
@ -45,6 +45,7 @@ export function LoginForm({
|
||||
|
||||
const handleLogin = useCallback(
|
||||
async ({ rememberMe: _rememberMe, ...formData }: LoginFormValues) => {
|
||||
void _rememberMe;
|
||||
clearError();
|
||||
try {
|
||||
// formData already matches LoginRequest schema (email, password)
|
||||
|
||||
@ -11,10 +11,7 @@ import { Button, Input, ErrorMessage } from "@/components/atoms";
|
||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||
import { usePasswordReset } from "../../hooks/use-auth";
|
||||
import { useZodForm } from "@customer-portal/validation";
|
||||
import {
|
||||
passwordResetRequestSchema,
|
||||
passwordResetSchema,
|
||||
} from "@customer-portal/domain/auth";
|
||||
import { passwordResetRequestSchema, passwordResetSchema } from "@customer-portal/domain/auth";
|
||||
import { z } from "zod";
|
||||
|
||||
interface PasswordResetFormProps {
|
||||
@ -38,7 +35,7 @@ export function PasswordResetForm({
|
||||
|
||||
// Zod form for password reset request - uses domain schema
|
||||
type PasswordResetRequestData = z.infer<typeof passwordResetRequestSchema>;
|
||||
|
||||
|
||||
const requestForm = useZodForm<PasswordResetRequestData>({
|
||||
schema: passwordResetRequestSchema,
|
||||
initialValues: { email: "" },
|
||||
@ -56,7 +53,7 @@ export function PasswordResetForm({
|
||||
|
||||
/**
|
||||
* Frontend reset form schema - extends domain passwordResetSchema with confirmPassword
|
||||
*
|
||||
*
|
||||
* Single source of truth: Domain layer defines validation rules
|
||||
* Frontend only adds: confirmPassword field and password matching logic
|
||||
*/
|
||||
@ -80,6 +77,7 @@ export function PasswordResetForm({
|
||||
schema: resetFormSchema,
|
||||
initialValues: { token: token || "", password: "", confirmPassword: "" },
|
||||
onSubmit: async ({ confirmPassword: _ignore, ...data }) => {
|
||||
void _ignore;
|
||||
try {
|
||||
await resetPassword(data.token, data.password);
|
||||
onSuccess?.();
|
||||
@ -92,8 +90,6 @@ export function PasswordResetForm({
|
||||
});
|
||||
|
||||
// Get the current form based on mode
|
||||
const currentForm = mode === "request" ? requestForm : resetForm;
|
||||
|
||||
// Handle errors from auth hooks
|
||||
useEffect(() => {
|
||||
if (error) {
|
||||
@ -106,7 +102,7 @@ export function PasswordResetForm({
|
||||
clearError();
|
||||
requestForm.reset();
|
||||
resetForm.reset();
|
||||
}, [mode, clearError]);
|
||||
}, [mode, clearError, requestForm, resetForm]);
|
||||
|
||||
if (mode === "request") {
|
||||
return (
|
||||
|
||||
@ -33,7 +33,7 @@ export function SetPasswordForm({
|
||||
|
||||
/**
|
||||
* Frontend form schema - extends domain setPasswordRequestSchema with confirmPassword
|
||||
*
|
||||
*
|
||||
* Single source of truth: Domain layer defines validation rules
|
||||
* Frontend only adds: confirmPassword field and password matching logic
|
||||
*/
|
||||
@ -61,6 +61,8 @@ export function SetPasswordForm({
|
||||
confirmPassword: "",
|
||||
},
|
||||
onSubmit: async ({ confirmPassword: _ignore, ...data }) => {
|
||||
void _ignore;
|
||||
clearError();
|
||||
try {
|
||||
await setPassword(data.email, data.password);
|
||||
onSuccess?.();
|
||||
@ -84,7 +86,7 @@ export function SetPasswordForm({
|
||||
if (email && email !== form.values.email) {
|
||||
form.setValue("email", email);
|
||||
}
|
||||
}, [email, form.values.email]);
|
||||
}, [email, form]);
|
||||
|
||||
return (
|
||||
<div className={`space-y-6 ${className}`}>
|
||||
|
||||
@ -5,7 +5,6 @@
|
||||
|
||||
"use client";
|
||||
|
||||
import { Input } from "@/components/atoms";
|
||||
import { FormField } from "@/components/molecules/FormField/FormField";
|
||||
|
||||
interface AccountStepProps {
|
||||
|
||||
@ -28,7 +28,7 @@ interface SignupFormProps {
|
||||
|
||||
/**
|
||||
* Frontend form schema - extends domain signupInputSchema with UI-specific fields
|
||||
*
|
||||
*
|
||||
* Single source of truth: Domain layer (signupInputSchema) defines all validation rules
|
||||
* Frontend only adds: confirmPassword field and password matching logic
|
||||
*/
|
||||
@ -36,7 +36,7 @@ export const signupFormSchema = signupInputSchema
|
||||
.extend({
|
||||
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",
|
||||
path: ["acceptTerms"],
|
||||
})
|
||||
@ -63,6 +63,7 @@ export function SignupForm({
|
||||
|
||||
const handleSignup = useCallback(
|
||||
async ({ confirmPassword: _confirm, ...formData }: SignupFormValues) => {
|
||||
void _confirm;
|
||||
clearError();
|
||||
try {
|
||||
const normalizeCountryCode = (value?: string) => {
|
||||
@ -73,8 +74,7 @@ export function SignupForm({
|
||||
|
||||
const normalizedAddress = formData.address
|
||||
? (() => {
|
||||
const countryValue =
|
||||
formData.address.country || formData.address.countryCode || "";
|
||||
const countryValue = formData.address.country || formData.address.countryCode || "";
|
||||
const normalizedCountry = normalizeCountryCode(countryValue);
|
||||
return {
|
||||
...formData.address,
|
||||
|
||||
@ -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 });
|
||||
try {
|
||||
const response = await apiClient.POST("/api/auth/change-password", {
|
||||
|
||||
@ -10,7 +10,6 @@ import {
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { StatusPill } 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"> {
|
||||
status: string;
|
||||
|
||||
@ -9,11 +9,10 @@ import {
|
||||
ClockIcon,
|
||||
ArrowRightIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { BillingStatusBadge } from "../BillingStatusBadge";
|
||||
import type { BillingSummary } from "@customer-portal/domain/billing";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
|
||||
const { formatCurrency, getCurrencyLocale } = Formatting;
|
||||
const { formatCurrency } = Formatting;
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface BillingSummaryProps extends React.HTMLAttributes<HTMLDivElement> {
|
||||
@ -46,8 +45,7 @@ const BillingSummary = forwardRef<HTMLDivElement, BillingSummaryProps>(
|
||||
);
|
||||
}
|
||||
|
||||
const formatAmount = (amount: number) =>
|
||||
formatCurrency(amount, summary.currency);
|
||||
const formatAmount = (amount: number) => formatCurrency(amount, summary.currency);
|
||||
|
||||
const summaryItems = [
|
||||
{
|
||||
|
||||
@ -2,11 +2,7 @@
|
||||
|
||||
import React from "react";
|
||||
import { Skeleton } from "@/components/atoms/loading-skeleton";
|
||||
import {
|
||||
ArrowTopRightOnSquareIcon,
|
||||
ArrowDownTrayIcon,
|
||||
ServerIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { ArrowTopRightOnSquareIcon, ArrowDownTrayIcon } from "@heroicons/react/24/outline";
|
||||
import { format } from "date-fns";
|
||||
import type { Invoice } from "@customer-portal/domain/billing";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
@ -29,22 +25,12 @@ interface InvoiceHeaderProps {
|
||||
invoice: Invoice;
|
||||
loadingDownload?: boolean;
|
||||
loadingPayment?: boolean;
|
||||
loadingPaymentMethods?: boolean;
|
||||
onDownload?: () => void;
|
||||
onPay?: () => void;
|
||||
onManagePaymentMethods?: () => void;
|
||||
}
|
||||
|
||||
export function InvoiceHeader(props: InvoiceHeaderProps) {
|
||||
const {
|
||||
invoice,
|
||||
loadingDownload,
|
||||
loadingPayment,
|
||||
loadingPaymentMethods,
|
||||
onDownload,
|
||||
onPay,
|
||||
onManagePaymentMethods,
|
||||
} = props;
|
||||
const { invoice, loadingDownload, loadingPayment, onDownload, onPay } = props;
|
||||
|
||||
return (
|
||||
<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">
|
||||
{/* Structured Header Layout */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 items-center">
|
||||
|
||||
{/* Left Section - Invoice Info */}
|
||||
<div className="lg:col-span-1">
|
||||
<div className="space-y-2">
|
||||
|
||||
@ -23,27 +23,39 @@ export function InvoiceItems({ items = [], currency }: InvoiceItemsProps) {
|
||||
|
||||
const renderItemContent = (item: InvoiceItem, index: number) => {
|
||||
const isLinked = hasServiceConnection(item);
|
||||
|
||||
|
||||
const itemContent = (
|
||||
<div
|
||||
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
|
||||
? 'hover:bg-blue-50 hover:border-blue-200 cursor-pointer group'
|
||||
: 'bg-slate-50/50'
|
||||
isLinked
|
||||
? "hover:bg-blue-50 hover:border-blue-200 cursor-pointer group"
|
||||
: "bg-slate-50/50"
|
||||
}`}
|
||||
>
|
||||
<div className="flex-1 pr-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="flex-1">
|
||||
<div className={`font-semibold mb-1 ${
|
||||
isLinked ? 'text-blue-900 group-hover:text-blue-700' : 'text-slate-900'
|
||||
}`}>
|
||||
<div
|
||||
className={`font-semibold mb-1 ${
|
||||
isLinked ? "text-blue-900 group-hover:text-blue-700" : "text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{item.description}
|
||||
{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">
|
||||
<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
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
@ -56,14 +68,22 @@ export function InvoiceItems({ items = [], currency }: InvoiceItemsProps) {
|
||||
{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">
|
||||
<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>
|
||||
Service #{item.serviceId}
|
||||
</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">
|
||||
<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>
|
||||
One-time item
|
||||
</span>
|
||||
@ -73,9 +93,11 @@ export function InvoiceItems({ items = [], currency }: InvoiceItemsProps) {
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className={`text-xl font-bold ${
|
||||
isLinked ? 'text-blue-900 group-hover:text-blue-700' : 'text-slate-900'
|
||||
}`}>
|
||||
<div
|
||||
className={`text-xl font-bold ${
|
||||
isLinked ? "text-blue-900 group-hover:text-blue-700" : "text-slate-900"
|
||||
}`}
|
||||
>
|
||||
{formatCurrency(item.amount || 0, currency)}
|
||||
</div>
|
||||
</div>
|
||||
@ -84,21 +106,13 @@ export function InvoiceItems({ items = [], currency }: InvoiceItemsProps) {
|
||||
|
||||
if (isLinked) {
|
||||
return (
|
||||
<Link
|
||||
key={item.id}
|
||||
href={`/subscriptions/${item.serviceId}`}
|
||||
className="block"
|
||||
>
|
||||
<Link key={item.id} href={`/subscriptions/${item.serviceId}`} className="block">
|
||||
{itemContent}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div key={item.id}>
|
||||
{itemContent}
|
||||
</div>
|
||||
);
|
||||
return <div key={item.id}>{itemContent}</div>;
|
||||
};
|
||||
|
||||
return (
|
||||
@ -130,8 +144,18 @@ export function InvoiceItems({ items = [], currency }: InvoiceItemsProps) {
|
||||
) : (
|
||||
<div className="text-center py-8">
|
||||
<div className="text-slate-400 mb-2">
|
||||
<svg 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
|
||||
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>
|
||||
</div>
|
||||
<p className="text-slate-500">No items found on this invoice.</p>
|
||||
|
||||
@ -3,7 +3,7 @@ import { format, formatDistanceToNowStrict } from "date-fns";
|
||||
import { ArrowDownTrayIcon, ArrowTopRightOnSquareIcon } from "@heroicons/react/24/outline";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
|
||||
const { formatCurrency, getCurrencyLocale } = Formatting;
|
||||
const { formatCurrency } = Formatting;
|
||||
import type { Invoice } from "@customer-portal/domain/billing";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { StatusPill } from "@/components/atoms/status-pill";
|
||||
@ -17,7 +17,9 @@ interface InvoiceSummaryBarProps {
|
||||
onPay?: () => void;
|
||||
}
|
||||
|
||||
const statusVariantMap: Partial<Record<Invoice["status"], "success" | "warning" | "error" | "neutral">> = {
|
||||
const statusVariantMap: Partial<
|
||||
Record<Invoice["status"], "success" | "warning" | "error" | "neutral">
|
||||
> = {
|
||||
Paid: "success",
|
||||
Unpaid: "warning",
|
||||
Overdue: "error",
|
||||
@ -46,12 +48,16 @@ function formatDisplayDate(dateString?: string) {
|
||||
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 (status === "Paid") return null;
|
||||
|
||||
if (status === "Overdue" && daysOverdue) {
|
||||
return `${daysOverdue} day${daysOverdue !== 1 ? 's' : ''} overdue`;
|
||||
return `${daysOverdue} day${daysOverdue !== 1 ? "s" : ""} overdue`;
|
||||
} else if (status === "Unpaid") {
|
||||
const dueDate = new Date(dateString);
|
||||
if (Number.isNaN(dueDate.getTime())) return null;
|
||||
@ -70,8 +76,7 @@ export function InvoiceSummaryBar({
|
||||
onPay,
|
||||
}: InvoiceSummaryBarProps) {
|
||||
const formattedTotal = useMemo(
|
||||
() =>
|
||||
formatCurrency(invoice.total, invoice.currency),
|
||||
() => formatCurrency(invoice.total, invoice.currency),
|
||||
[invoice.currency, invoice.total]
|
||||
);
|
||||
|
||||
@ -96,7 +101,6 @@ export function InvoiceSummaryBar({
|
||||
<div className="max-w-4xl mx-auto">
|
||||
{/* Header layout with proper alignment */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-6">
|
||||
|
||||
{/* Left section: Amount, currency, and status */}
|
||||
<div className="flex-1">
|
||||
<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">
|
||||
{invoice.currency?.toUpperCase()}
|
||||
</div>
|
||||
<StatusPill
|
||||
size="md"
|
||||
variant={statusVariant}
|
||||
label={statusLabel}
|
||||
className="font-semibold"
|
||||
<StatusPill
|
||||
size="md"
|
||||
variant={statusVariant}
|
||||
label={statusLabel}
|
||||
className="font-semibold"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
||||
{/* Due date information */}
|
||||
{(dueDisplay || relativeDue) && (
|
||||
<div className="flex items-center gap-2 text-sm text-slate-600">
|
||||
@ -121,10 +125,12 @@ export function InvoiceSummaryBar({
|
||||
{relativeDue && (
|
||||
<>
|
||||
{dueDisplay && <span className="text-slate-400">•</span>}
|
||||
<span className={cn(
|
||||
"font-medium",
|
||||
invoice.status === "Overdue" ? "text-red-600" : "text-amber-600"
|
||||
)}>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium",
|
||||
invoice.status === "Overdue" ? "text-red-600" : "text-amber-600"
|
||||
)}
|
||||
>
|
||||
{relativeDue}
|
||||
</span>
|
||||
</>
|
||||
@ -163,15 +169,11 @@ export function InvoiceSummaryBar({
|
||||
|
||||
{/* 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="font-semibold text-slate-900">
|
||||
Invoice #{invoice.number}
|
||||
</div>
|
||||
<div className="font-semibold text-slate-900">Invoice #{invoice.number}</div>
|
||||
{issuedDisplay && (
|
||||
<>
|
||||
<span className="hidden sm:inline lg:hidden xl:inline text-slate-400">•</span>
|
||||
<div>
|
||||
Issued {issuedDisplay}
|
||||
</div>
|
||||
<div>Issued {issuedDisplay}</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -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="px-8 py-6">
|
||||
<h3 className="text-lg font-semibold text-slate-900 mb-6">Invoice Summary</h3>
|
||||
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="flex justify-between items-center text-slate-600">
|
||||
<span className="font-medium">Subtotal</span>
|
||||
|
||||
@ -55,7 +55,8 @@ export function InvoicesList({
|
||||
error: unknown;
|
||||
};
|
||||
|
||||
const invoices = data?.invoices || [];
|
||||
const rawInvoices = data?.invoices;
|
||||
const invoices = useMemo(() => rawInvoices ?? [], [rawInvoices]);
|
||||
const pagination = data?.pagination;
|
||||
|
||||
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">
|
||||
{/* Title Section */}
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-gray-900">
|
||||
Invoices
|
||||
</h2>
|
||||
<h2 className="text-lg font-semibold text-gray-900">Invoices</h2>
|
||||
{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">
|
||||
{pagination.totalItems} total
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Search Input */}
|
||||
@ -150,9 +149,9 @@ export function InvoicesList({
|
||||
|
||||
{/* Invoice Table */}
|
||||
<div className="bg-white rounded-xl border border-gray-200/60 shadow-sm overflow-hidden">
|
||||
<InvoiceTable
|
||||
invoices={filtered}
|
||||
loading={isLoading}
|
||||
<InvoiceTable
|
||||
invoices={filtered}
|
||||
loading={isLoading}
|
||||
compact={compact}
|
||||
className="border-0 rounded-none shadow-none"
|
||||
/>
|
||||
|
||||
@ -5,21 +5,18 @@ import { useRouter } from "next/navigation";
|
||||
import { format } from "date-fns";
|
||||
import {
|
||||
DocumentTextIcon,
|
||||
ArrowTopRightOnSquareIcon,
|
||||
CheckCircleIcon,
|
||||
ExclamationTriangleIcon,
|
||||
ClockIcon,
|
||||
CreditCardIcon,
|
||||
ArrowDownTrayIcon,
|
||||
} from "@heroicons/react/24/outline";
|
||||
import { CheckCircleIcon as CheckCircleIconSolid } from "@heroicons/react/24/solid";
|
||||
import { DataTable } from "@/components/molecules/DataTable/DataTable";
|
||||
import { Button } from "@/components/atoms/button";
|
||||
import { BillingStatusBadge } from "../BillingStatusBadge";
|
||||
import type { Invoice } from "@customer-portal/domain/billing";
|
||||
import { Formatting } from "@customer-portal/domain/toolkit";
|
||||
|
||||
const { formatCurrency, getCurrencyLocale } = Formatting;
|
||||
const { formatCurrency } = Formatting;
|
||||
import { cn } from "@/lib/utils";
|
||||
import { useCreateInvoiceSsoLink } from "@/features/billing/hooks/useBilling";
|
||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||
@ -75,9 +72,9 @@ export function InvoiceTable({
|
||||
event.stopPropagation(); // Prevent row click
|
||||
setPaymentLoading(invoice.id);
|
||||
try {
|
||||
const ssoLink = await createSsoLinkMutation.mutateAsync({
|
||||
invoiceId: invoice.id,
|
||||
target: "pay"
|
||||
const ssoLink = await createSsoLinkMutation.mutateAsync({
|
||||
invoiceId: invoice.id,
|
||||
target: "pay",
|
||||
});
|
||||
openSsoLink(ssoLink.url, { newTab: true });
|
||||
} catch (err) {
|
||||
@ -91,9 +88,9 @@ export function InvoiceTable({
|
||||
event.stopPropagation(); // Prevent row click
|
||||
setDownloadLoading(invoice.id);
|
||||
try {
|
||||
const ssoLink = await createSsoLinkMutation.mutateAsync({
|
||||
invoiceId: invoice.id,
|
||||
target: "download"
|
||||
const ssoLink = await createSsoLinkMutation.mutateAsync({
|
||||
invoiceId: invoice.id,
|
||||
target: "download",
|
||||
});
|
||||
openSsoLink(ssoLink.url, { newTab: false });
|
||||
} catch (err) {
|
||||
@ -113,13 +110,9 @@ export function InvoiceTable({
|
||||
const statusIcon = getStatusIcon(invoice.status);
|
||||
return (
|
||||
<div className="flex items-start space-x-3 py-3">
|
||||
<div className="flex-shrink-0 mt-1">
|
||||
{statusIcon}
|
||||
</div>
|
||||
<div className="flex-shrink-0 mt-1">{statusIcon}</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="font-semibold text-gray-900 text-sm">
|
||||
{invoice.number}
|
||||
</div>
|
||||
<div className="font-semibold text-gray-900 text-sm">{invoice.number}</div>
|
||||
{!compact && invoice.description && (
|
||||
<div className="text-sm text-gray-600 mt-1.5 line-clamp-2 leading-relaxed">
|
||||
{invoice.description}
|
||||
@ -155,7 +148,7 @@ export function InvoiceTable({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
case "Overdue":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@ -164,12 +157,12 @@ export function InvoiceTable({
|
||||
</span>
|
||||
{invoice.daysOverdue && (
|
||||
<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>
|
||||
);
|
||||
|
||||
|
||||
case "Unpaid":
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
@ -183,18 +176,14 @@ export function InvoiceTable({
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
|
||||
default:
|
||||
// Use the existing BillingStatusBadge for other statuses
|
||||
return <BillingStatusBadge status={invoice.status} />;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="py-3">
|
||||
{renderStatusWithDate()}
|
||||
</div>
|
||||
);
|
||||
|
||||
return <div className="py-3">{renderStatusWithDate()}</div>;
|
||||
},
|
||||
},
|
||||
{
|
||||
@ -221,7 +210,7 @@ export function InvoiceTable({
|
||||
const canPay = invoice.status === "Unpaid" || invoice.status === "Overdue";
|
||||
const isPaymentLoading = paymentLoading === invoice.id;
|
||||
const isDownloadLoading = downloadLoading === invoice.id;
|
||||
|
||||
|
||||
return (
|
||||
<div className="py-3 flex justify-end items-center space-x-2">
|
||||
{/* Payment Button - Only for unpaid invoices - Always on the left */}
|
||||
@ -238,7 +227,7 @@ export function InvoiceTable({
|
||||
Pay Now
|
||||
</Button>
|
||||
)}
|
||||
|
||||
|
||||
{/* Download Button - Always available and always on the right */}
|
||||
<Button
|
||||
size="sm"
|
||||
@ -247,7 +236,9 @@ export function InvoiceTable({
|
||||
void handleDownload(invoice, event);
|
||||
}}
|
||||
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"
|
||||
title="Download PDF"
|
||||
>
|
||||
|
||||
@ -1,6 +1,11 @@
|
||||
"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 { cn } from "@/lib/utils";
|
||||
import type { ReactNode } from "react";
|
||||
@ -14,19 +19,22 @@ interface PaymentMethodCardProps {
|
||||
|
||||
const getBrandColor = (brand?: string) => {
|
||||
const brandLower = brand?.toLowerCase() || "";
|
||||
|
||||
|
||||
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("amex") || brandLower.includes("american")) return "from-gray-700 to-gray-800";
|
||||
if (brandLower.includes("mastercard") || brandLower.includes("master"))
|
||||
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("mobile")) return "from-purple-500 to-purple-600";
|
||||
|
||||
|
||||
return "from-gray-500 to-gray-600"; // Default
|
||||
};
|
||||
|
||||
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)) {
|
||||
return (
|
||||
<div className={`${baseClasses} from-green-500 to-green-600`}>
|
||||
@ -34,10 +42,12 @@ const getMethodIcon = (type: PaymentMethod["type"], brand?: string) => {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
const brandColor = getBrandColor(brand);
|
||||
const IconComponent = brand?.toLowerCase().includes("mobile") ? DevicePhoneMobileIcon : CreditCardIcon;
|
||||
|
||||
const IconComponent = brand?.toLowerCase().includes("mobile")
|
||||
? DevicePhoneMobileIcon
|
||||
: CreditCardIcon;
|
||||
|
||||
return (
|
||||
<div className={`${baseClasses} ${brandColor}`}>
|
||||
<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";
|
||||
|
||||
const isBankAccount = (type: PaymentMethod["type"]) =>
|
||||
const isBankAccount = (type: PaymentMethod["type"]) =>
|
||||
type === "BankAccount" || type === "RemoteBankAccount";
|
||||
|
||||
const formatCardDisplay = (method: PaymentMethod) => {
|
||||
@ -64,7 +74,7 @@ const formatCardDisplay = (method: PaymentMethod) => {
|
||||
if (isBankAccount(method.type)) {
|
||||
return method.bankName || "Bank Account";
|
||||
}
|
||||
|
||||
|
||||
return method.description || "Payment Method";
|
||||
};
|
||||
|
||||
@ -76,7 +86,7 @@ const formatCardBrand = (method: PaymentMethod) => {
|
||||
if (isBankAccount(method.type) && method.bankName) {
|
||||
return method.bankName;
|
||||
}
|
||||
|
||||
|
||||
return null;
|
||||
};
|
||||
|
||||
@ -116,11 +126,9 @@ export function PaymentMethodCard({
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
<div className="flex items-center gap-4 text-sm">
|
||||
{cardBrand && (
|
||||
<span className="text-gray-600 font-medium">{cardBrand}</span>
|
||||
)}
|
||||
{cardBrand && <span className="text-gray-600 font-medium">{cardBrand}</span>}
|
||||
{expiry && (
|
||||
<>
|
||||
{cardBrand && <span className="text-gray-300">•</span>}
|
||||
@ -128,7 +136,7 @@ export function PaymentMethodCard({
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
{paymentMethod.isDefault && (
|
||||
<div className="text-xs text-blue-600 font-medium mt-1">
|
||||
This card will be used for automatic payments
|
||||
@ -137,9 +145,7 @@ export function PaymentMethodCard({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showActions && actionSlot && (
|
||||
<div className="flex-shrink-0 ml-4">{actionSlot}</div>
|
||||
)}
|
||||
{showActions && actionSlot && <div className="flex-shrink-0 ml-4">{actionSlot}</div>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@ -66,8 +66,16 @@ const PaymentMethodCard = forwardRef<HTMLDivElement, PaymentMethodCardProps>(
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
const { type, description, gatewayName, isDefault, expiryDate, bankName, cardType, cardLastFour } =
|
||||
paymentMethod;
|
||||
const {
|
||||
type,
|
||||
description,
|
||||
gatewayName,
|
||||
isDefault,
|
||||
expiryDate,
|
||||
bankName,
|
||||
cardType,
|
||||
cardLastFour,
|
||||
} = paymentMethod;
|
||||
|
||||
const formatExpiryDate = (expiry?: string) => {
|
||||
if (!expiry) return null;
|
||||
|
||||
@ -10,10 +10,10 @@ import {
|
||||
} from "@tanstack/react-query";
|
||||
|
||||
// ✅ Generic utilities from lib
|
||||
import {
|
||||
apiClient,
|
||||
queryKeys,
|
||||
getDataOrDefault,
|
||||
import {
|
||||
apiClient,
|
||||
queryKeys,
|
||||
getDataOrDefault,
|
||||
getDataOrThrow,
|
||||
type QueryParams,
|
||||
} from "@/lib/api";
|
||||
|
||||
@ -181,7 +181,7 @@ export function InvoiceDetailContainer() {
|
||||
<div className="space-y-8">
|
||||
{/* Invoice Items */}
|
||||
<InvoiceItems items={invoice.items} currency={invoice.currency} />
|
||||
|
||||
|
||||
{/* Invoice Summary - Full Width */}
|
||||
<div className="border-t border-slate-200 pt-8">
|
||||
<InvoiceTotals
|
||||
|
||||
@ -10,7 +10,11 @@ import { useAuthStore } from "@/features/auth/services/auth.store";
|
||||
import { isApiError } from "@/lib/api";
|
||||
import { openSsoLink } from "@/features/billing/utils/sso";
|
||||
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 { InlineToast } from "@/components/atoms/inline-toast";
|
||||
import { SectionHeader } from "@/components/molecules";
|
||||
@ -42,8 +46,7 @@ export function PaymentMethodsContainer() {
|
||||
const result = await paymentMethodsQuery.refetch();
|
||||
return { data: result.data };
|
||||
},
|
||||
hasMethods: data =>
|
||||
Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)),
|
||||
hasMethods: data => Boolean(data && (data.totalCount > 0 || data.paymentMethods.length > 0)),
|
||||
attachFocusListeners: true,
|
||||
});
|
||||
|
||||
@ -54,14 +57,21 @@ export function PaymentMethodsContainer() {
|
||||
}
|
||||
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
const ssoLink = await createPaymentMethodsSsoLink.mutateAsync();
|
||||
openSsoLink(ssoLink.url, { newTab: true });
|
||||
} catch (err: unknown) {
|
||||
logger.error(err, "Failed to open payment methods");
|
||||
// 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.");
|
||||
} else {
|
||||
setError("Unable to access payment methods. Please try again later.");
|
||||
@ -107,7 +117,7 @@ export function PaymentMethodsContainer() {
|
||||
text={paymentRefresh.toast.text}
|
||||
tone={paymentRefresh.toast.tone}
|
||||
/>
|
||||
|
||||
|
||||
<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">
|
||||
{!hasCheckedAuth || isLoadingPaymentMethods || isFetchingPaymentMethods ? (
|
||||
@ -145,7 +155,8 @@ export function PaymentMethodsContainer() {
|
||||
<div>
|
||||
<h2 className="text-xl font-semibold text-gray-900">Your Payment Methods</h2>
|
||||
<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>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
@ -159,16 +170,14 @@ export function PaymentMethodsContainer() {
|
||||
>
|
||||
{createPaymentMethodsSsoLink.isPending ? "Opening..." : "Manage Cards"}
|
||||
</Button>
|
||||
<p className="text-xs text-gray-500 mt-1">
|
||||
Opens in a new tab for security
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">Opens in a new tab for security</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
<div className="p-6">
|
||||
<div className="space-y-4">
|
||||
{paymentMethodsData.paymentMethods.map((paymentMethod) => (
|
||||
{paymentMethodsData.paymentMethods.map(paymentMethod => (
|
||||
<PaymentMethodCard
|
||||
key={paymentMethod.id}
|
||||
paymentMethod={paymentMethod}
|
||||
@ -204,9 +213,7 @@ export function PaymentMethodsContainer() {
|
||||
>
|
||||
{createPaymentMethodsSsoLink.isPending ? "Opening..." : "Manage Cards"}
|
||||
</Button>
|
||||
<p className="text-sm text-gray-500">
|
||||
Opens in a new tab for security
|
||||
</p>
|
||||
<p className="text-sm text-gray-500">Opens in a new tab for security</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
@ -232,7 +239,9 @@ export function PaymentMethodsContainer() {
|
||||
</div>
|
||||
|
||||
<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">
|
||||
<li>• Credit Cards (Visa, MasterCard, American Express)</li>
|
||||
<li>• Debit Cards</li>
|
||||
|
||||
@ -241,8 +241,9 @@ export function AddressConfirmation({
|
||||
if (!billingInfo) return null;
|
||||
|
||||
const address = billingInfo.address;
|
||||
const countryLabel =
|
||||
address?.country ? getCountryName(address.country) ?? address.country : null;
|
||||
const countryLabel = address?.country
|
||||
? (getCountryName(address.country) ?? address.country)
|
||||
: null;
|
||||
|
||||
return wrap(
|
||||
<>
|
||||
@ -368,7 +369,7 @@ export function AddressConfirmation({
|
||||
{option.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center space-x-3 pt-4">
|
||||
@ -396,9 +397,7 @@ export function AddressConfirmation({
|
||||
<div className="space-y-4">
|
||||
<div className="text-gray-900 space-y-1">
|
||||
<p className="font-semibold text-base">{address.address1}</p>
|
||||
{address.address2 ? (
|
||||
<p className="text-gray-700">{address.address2}</p>
|
||||
) : null}
|
||||
{address.address2 ? <p className="text-gray-700">{address.address2}</p> : null}
|
||||
<p className="text-gray-700">
|
||||
{[address.city, address.state].filter(Boolean).join(", ")}
|
||||
{address.postcode ? ` ${address.postcode}` : ""}
|
||||
|
||||
@ -256,7 +256,9 @@ export function EnhancedOrderSummary({
|
||||
<div className="mb-4">
|
||||
<div className="flex justify-between text-sm">
|
||||
<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>
|
||||
)}
|
||||
|
||||
@ -196,10 +196,9 @@ export function OrderSummary({
|
||||
<span className="text-gray-600">{String(addon.name)}</span>
|
||||
<span className="font-medium">
|
||||
¥
|
||||
{(
|
||||
addon.billingCycle === "Monthly"
|
||||
? addon.monthlyPrice ?? 0
|
||||
: addon.oneTimePrice ?? 0
|
||||
{(addon.billingCycle === "Monthly"
|
||||
? (addon.monthlyPrice ?? 0)
|
||||
: (addon.oneTimePrice ?? 0)
|
||||
).toLocaleString()}
|
||||
{addon.billingCycle === "Monthly" ? "/mo" : " one-time"}
|
||||
</span>
|
||||
|
||||
@ -60,7 +60,6 @@ export function PricingDisplay({
|
||||
infoText,
|
||||
children,
|
||||
}: PricingDisplayProps) {
|
||||
|
||||
const getCurrencyIcon = () => {
|
||||
if (!showCurrencySymbol) return null;
|
||||
return currency === "JPY" ? <CurrencyYenIcon className="h-5 w-5" /> : "$";
|
||||
|
||||
@ -110,14 +110,17 @@ function OrderSummary({
|
||||
{mode && <p className="text-sm text-gray-600">Access Mode: {mode}</p>}
|
||||
</div>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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">
|
||||
<h4 className="font-medium text-gray-900 mb-3">Installation</h4>
|
||||
<div className="flex justify-between text-sm">
|
||||
|
||||
@ -174,7 +174,8 @@ export function SimConfigureView({
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<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>
|
||||
{plan.simHasFamilyDiscount && (
|
||||
<div className="text-sm text-green-600 font-medium">Discounted Price</div>
|
||||
|
||||
@ -19,9 +19,7 @@ const parseActivationType = (value: string | null): ActivationType | null => {
|
||||
return null;
|
||||
};
|
||||
|
||||
const parsePortingGender = (
|
||||
value: string | null
|
||||
): MnpData["portingGender"] | undefined => {
|
||||
const parsePortingGender = (value: string | null): MnpData["portingGender"] | undefined => {
|
||||
if (value === "Male" || value === "Female" || value === "Corporate/Other") {
|
||||
return value;
|
||||
}
|
||||
@ -41,9 +39,7 @@ export function useInternetConfigureParams() {
|
||||
const params = useSearchParams();
|
||||
const accessModeParam = params.get("accessMode");
|
||||
const accessMode: AccessMode | null =
|
||||
accessModeParam === "IPoE-BYOR" || accessModeParam === "PPPoE"
|
||||
? accessModeParam
|
||||
: null;
|
||||
accessModeParam === "IPoE-BYOR" || accessModeParam === "PPPoE" ? accessModeParam : null;
|
||||
const installationSku = params.get("installationSku");
|
||||
const addonSkus = params.getAll("addonSku");
|
||||
|
||||
@ -59,11 +55,9 @@ export function useSimConfigureParams() {
|
||||
|
||||
const simType = parseSimCardType(params.get("simType"));
|
||||
const activationType = parseActivationType(params.get("activationType"));
|
||||
const scheduledAt =
|
||||
coalesce(params.get("scheduledAt"), params.get("scheduledDate")) ?? null;
|
||||
const scheduledAt = coalesce(params.get("scheduledAt"), params.get("scheduledDate")) ?? null;
|
||||
const addonSkus = params.getAll("addonSku");
|
||||
const isMnp =
|
||||
coalesce(params.get("isMnp"), params.get("wantsMnp"))?.toLowerCase() === "true";
|
||||
const isMnp = coalesce(params.get("isMnp"), params.get("wantsMnp"))?.toLowerCase() === "true";
|
||||
const eid = params.get("eid") ?? null;
|
||||
|
||||
const mnp: Partial<MnpData> = {
|
||||
@ -86,14 +80,8 @@ export function useSimConfigureParams() {
|
||||
params.get("mvnoAccountNumber"),
|
||||
params.get("mnp_mvnoAccountNumber")
|
||||
),
|
||||
portingLastName: coalesce(
|
||||
params.get("portingLastName"),
|
||||
params.get("mnp_portingLastName")
|
||||
),
|
||||
portingFirstName: coalesce(
|
||||
params.get("portingFirstName"),
|
||||
params.get("mnp_portingFirstName")
|
||||
),
|
||||
portingLastName: coalesce(params.get("portingLastName"), params.get("mnp_portingLastName")),
|
||||
portingFirstName: coalesce(params.get("portingFirstName"), params.get("mnp_portingFirstName")),
|
||||
portingLastNameKatakana: coalesce(
|
||||
params.get("portingLastNameKatakana"),
|
||||
params.get("mnp_portingLastNameKatakana")
|
||||
|
||||
@ -10,9 +10,7 @@ import type {
|
||||
} from "@customer-portal/domain/catalog";
|
||||
|
||||
type InstallationTerm = NonNullable<
|
||||
NonNullable<InternetInstallationCatalogItem["catalogMetadata"]>[
|
||||
"installationTerm"
|
||||
]
|
||||
NonNullable<InternetInstallationCatalogItem["catalogMetadata"]>["installationTerm"]
|
||||
>;
|
||||
|
||||
type InternetAccessMode = "IPoE-BYOR" | "PPPoE";
|
||||
|
||||
@ -12,7 +12,10 @@ import {
|
||||
type MnpData,
|
||||
} from "@customer-portal/domain/sim";
|
||||
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 = {
|
||||
// data
|
||||
@ -73,9 +76,7 @@ const parseActivationTypeParam = (value: string | null): ActivationType | null =
|
||||
return null;
|
||||
};
|
||||
|
||||
const parsePortingGenderParam = (
|
||||
value: string | null
|
||||
): MnpData["portingGender"] | undefined => {
|
||||
const parsePortingGenderParam = (value: string | null): MnpData["portingGender"] | undefined => {
|
||||
if (value === "Male" || value === "Female" || value === "Corporate/Other") {
|
||||
return value;
|
||||
}
|
||||
@ -161,9 +162,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
||||
|
||||
const resolvedParams = useMemo(() => {
|
||||
const initialSimType =
|
||||
configureParams.simType ??
|
||||
parseSimCardTypeParam(parsedSearchParams.get("simType")) ??
|
||||
"eSIM";
|
||||
configureParams.simType ?? parseSimCardTypeParam(parsedSearchParams.get("simType")) ?? "eSIM";
|
||||
const initialActivationType =
|
||||
configureParams.activationType ??
|
||||
parseActivationTypeParam(parsedSearchParams.get("activationType")) ??
|
||||
@ -203,14 +202,20 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
||||
const mnp = configureParams.mnp;
|
||||
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")),
|
||||
phoneNumber: paramFallback(mnp.phoneNumber, parsedSearchParams.get("mnpPhone")),
|
||||
mvnoAccountNumber: paramFallback(
|
||||
mnp.mvnoAccountNumber,
|
||||
parsedSearchParams.get("mvnoAccountNumber")
|
||||
),
|
||||
portingLastName: paramFallback(mnp.portingLastName, parsedSearchParams.get("portingLastName")),
|
||||
portingLastName: paramFallback(
|
||||
mnp.portingLastName,
|
||||
parsedSearchParams.get("portingLastName")
|
||||
),
|
||||
portingFirstName: paramFallback(
|
||||
mnp.portingFirstName,
|
||||
parsedSearchParams.get("portingFirstName")
|
||||
@ -413,10 +418,7 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
||||
params.set("activationType", values.activationType);
|
||||
if (values.scheduledActivationDate) {
|
||||
params.set("scheduledDate", values.scheduledActivationDate);
|
||||
params.set(
|
||||
"scheduledAt",
|
||||
values.scheduledActivationDate.replace(/-/g, "")
|
||||
);
|
||||
params.set("scheduledAt", values.scheduledActivationDate.replace(/-/g, ""));
|
||||
} else {
|
||||
params.delete("scheduledDate");
|
||||
params.delete("scheduledAt");
|
||||
@ -438,16 +440,13 @@ export function useSimConfigure(planId?: string): UseSimConfigureResult {
|
||||
if (simConfig.mnpPhone) params.set("mnpPhone", simConfig.mnpPhone);
|
||||
if (simConfig.mvnoAccountNumber)
|
||||
params.set("mvnoAccountNumber", simConfig.mvnoAccountNumber);
|
||||
if (simConfig.portingLastName)
|
||||
params.set("portingLastName", simConfig.portingLastName);
|
||||
if (simConfig.portingFirstName)
|
||||
params.set("portingFirstName", simConfig.portingFirstName);
|
||||
if (simConfig.portingLastName) params.set("portingLastName", simConfig.portingLastName);
|
||||
if (simConfig.portingFirstName) params.set("portingFirstName", simConfig.portingFirstName);
|
||||
if (simConfig.portingLastNameKatakana)
|
||||
params.set("portingLastNameKatakana", simConfig.portingLastNameKatakana);
|
||||
if (simConfig.portingFirstNameKatakana)
|
||||
params.set("portingFirstNameKatakana", simConfig.portingFirstNameKatakana);
|
||||
if (simConfig.portingGender)
|
||||
params.set("portingGender", simConfig.portingGender);
|
||||
if (simConfig.portingGender) params.set("portingGender", simConfig.portingGender);
|
||||
if (simConfig.portingDateOfBirth)
|
||||
params.set("portingDateOfBirth", simConfig.portingDateOfBirth);
|
||||
} else {
|
||||
|
||||
@ -33,13 +33,17 @@ export const catalogService = {
|
||||
},
|
||||
|
||||
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, []);
|
||||
return internetInstallationCatalogItemSchema.array().parse(data);
|
||||
},
|
||||
|
||||
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, []);
|
||||
return internetAddonCatalogItemSchema.array().parse(data);
|
||||
},
|
||||
@ -51,7 +55,9 @@ export const catalogService = {
|
||||
},
|
||||
|
||||
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, []);
|
||||
return simActivationFeeCatalogItemSchema.array().parse(data);
|
||||
},
|
||||
|
||||
@ -30,4 +30,3 @@ export function getDisplayPrice(item: CatalogProductBase): PriceInfo | null {
|
||||
currency,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@ -118,11 +118,11 @@ export function InternetPlansContainer() {
|
||||
<div className="max-w-6xl mx-auto">
|
||||
{/* Enhanced Back Button */}
|
||||
<div className="mb-8">
|
||||
<Button
|
||||
as="a"
|
||||
href="/catalog"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
<Button
|
||||
as="a"
|
||||
href="/catalog"
|
||||
variant="outline"
|
||||
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"
|
||||
leftIcon={<ArrowLeftIcon className="w-4 h-4" />}
|
||||
>
|
||||
@ -130,102 +130,98 @@ export function InternetPlansContainer() {
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Enhanced Header */}
|
||||
<div className="text-center mb-16 relative">
|
||||
{/* Background decoration */}
|
||||
<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 -bottom-20 -left-20 w-40 h-40 bg-gradient-to-tr from-indigo-400/10 to-purple-600/10 rounded-full blur-3xl"></div>
|
||||
</div>
|
||||
|
||||
<h1 className="text-5xl font-bold bg-gradient-to-r from-gray-900 via-blue-900 to-indigo-900 bg-clip-text text-transparent mb-6 relative">
|
||||
Choose Your Internet Plan
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 mb-8 max-w-3xl mx-auto leading-relaxed">
|
||||
High-speed fiber internet with reliable connectivity for your home or business
|
||||
</p>
|
||||
{/* Enhanced Header */}
|
||||
<div className="text-center mb-16 relative">
|
||||
{/* Background decoration */}
|
||||
<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 -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>
|
||||
|
||||
{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>
|
||||
<h1 className="text-5xl font-bold bg-gradient-to-r from-gray-900 via-blue-900 to-indigo-900 bg-clip-text text-transparent mb-6 relative">
|
||||
Choose Your Internet Plan
|
||||
</h1>
|
||||
<p className="text-xl text-gray-600 mb-8 max-w-3xl mx-auto leading-relaxed">
|
||||
High-speed fiber internet with reliable connectivity for your home or business
|
||||
</p>
|
||||
|
||||
{eligibility && (
|
||||
<div className="mt-8">
|
||||
<div
|
||||
className={`inline-flex items-center gap-3 px-8 py-4 rounded-2xl border backdrop-blur-sm shadow-lg hover:shadow-xl transition-all duration-300 ${getEligibilityColor(eligibility)}`}
|
||||
>
|
||||
{getEligibilityIcon(eligibility)}
|
||||
<span className="font-semibold text-lg">Available for: {eligibility}</span>
|
||||
</div>
|
||||
<p className="text-gray-600 mt-4 max-w-2xl mx-auto leading-relaxed">
|
||||
Plans shown are tailored to your house type and local infrastructure
|
||||
</p>
|
||||
</div>
|
||||
<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>
|
||||
</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'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>
|
||||
|
||||
{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'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>
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -7,7 +7,11 @@ import { ordersService } from "@/features/orders/services/orders.service";
|
||||
import { usePaymentMethods } from "@/features/billing/hooks/useBilling";
|
||||
import { usePaymentRefresh } from "@/features/billing/hooks/usePaymentRefresh";
|
||||
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 { useActiveSubscriptions } from "@/features/subscriptions/hooks/useSubscriptions";
|
||||
import {
|
||||
@ -261,8 +265,7 @@ export function useCheckout() {
|
||||
);
|
||||
} catch (error) {
|
||||
if (mounted) {
|
||||
const reason =
|
||||
error instanceof Error ? error.message : "Failed to load checkout data";
|
||||
const reason = error instanceof Error ? error.message : "Failed to load checkout data";
|
||||
setCheckoutState(createErrorState(new Error(reason)));
|
||||
}
|
||||
}
|
||||
@ -301,7 +304,10 @@ export function useCheckout() {
|
||||
? { simType: selections.simType as OrderConfigurations["simType"] }
|
||||
: {}),
|
||||
...(selections.activationType
|
||||
? { activationType: selections.activationType as OrderConfigurations["activationType"] }
|
||||
? {
|
||||
activationType:
|
||||
selections.activationType as OrderConfigurations["activationType"],
|
||||
}
|
||||
: {}),
|
||||
...(selections.scheduledAt ? { scheduledAt: selections.scheduledAt } : {}),
|
||||
...(selections.eid ? { eid: selections.eid } : {}),
|
||||
@ -313,7 +319,9 @@ export function useCheckout() {
|
||||
? { mvnoAccountNumber: selections.mvnoAccountNumber }
|
||||
: {}),
|
||||
...(selections.portingLastName ? { portingLastName: selections.portingLastName } : {}),
|
||||
...(selections.portingFirstName ? { portingFirstName: selections.portingFirstName } : {}),
|
||||
...(selections.portingFirstName
|
||||
? { portingFirstName: selections.portingFirstName }
|
||||
: {}),
|
||||
...(selections.portingLastNameKatakana
|
||||
? { portingLastNameKatakana: selections.portingLastNameKatakana }
|
||||
: {}),
|
||||
@ -366,7 +374,9 @@ export function useCheckout() {
|
||||
|
||||
if (orderType === ORDER_TYPE.SIM) {
|
||||
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) {
|
||||
throw new Error(
|
||||
|
||||
@ -14,7 +14,7 @@ interface UpcomingPaymentBannerProps {
|
||||
|
||||
export function UpcomingPaymentBanner({ invoice, onPay, loading }: UpcomingPaymentBannerProps) {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
|
||||
|
||||
return (
|
||||
<div id="attention" className="bg-white rounded-xl border border-orange-200 shadow-sm p-4">
|
||||
<div className="flex items-center gap-4">
|
||||
|
||||
@ -42,10 +42,7 @@ export function useDashboardSummary() {
|
||||
try {
|
||||
const response = await apiClient.GET<DashboardSummary>("/api/me/summary");
|
||||
if (!response.data) {
|
||||
throw new DashboardDataError(
|
||||
"FETCH_ERROR",
|
||||
"Dashboard summary response was empty"
|
||||
);
|
||||
throw new DashboardDataError("FETCH_ERROR", "Dashboard summary response was empty");
|
||||
}
|
||||
const parsed = dashboardSummarySchema.safeParse(response.data);
|
||||
if (!parsed.success) {
|
||||
|
||||
@ -6,10 +6,7 @@ import {
|
||||
type OrderDetails,
|
||||
type OrderSummary,
|
||||
} from "@customer-portal/domain/orders";
|
||||
import {
|
||||
assertSuccess,
|
||||
type DomainApiResponse,
|
||||
} from "@/lib/api/response-helpers";
|
||||
import { assertSuccess, type DomainApiResponse } from "@/lib/api/response-helpers";
|
||||
|
||||
async function createOrder(payload: CreateOrderRequest): Promise<{ sfOrderId: string }> {
|
||||
const body: CreateOrderRequest = {
|
||||
|
||||
@ -186,9 +186,7 @@ export function OrderDetailContainer() {
|
||||
<div>
|
||||
<div className="font-medium text-gray-900">{productName}</div>
|
||||
<div className="text-xs text-gray-500">SKU: {sku}</div>
|
||||
{billingCycle && (
|
||||
<div className="text-xs text-gray-500">{billingCycle}</div>
|
||||
)}
|
||||
{billingCycle && <div className="text-xs text-gray-500">{billingCycle}</div>}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<div className="text-sm text-gray-600">Qty: {item.quantity}</div>
|
||||
|
||||
@ -48,13 +48,7 @@ const formatQuota = (remainingMb: number) => {
|
||||
return `${remainingMb.toFixed(0)} MB`;
|
||||
};
|
||||
|
||||
const FeatureToggleRow = ({
|
||||
label,
|
||||
enabled,
|
||||
}: {
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
}) => (
|
||||
const FeatureToggleRow = ({ label, enabled }: { label: string; enabled: boolean }) => (
|
||||
<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
|
||||
@ -118,9 +112,7 @@ export function SimDetailsCard({
|
||||
<DevicePhoneMobileIcon className="h-5 w-5 text-gray-500" />
|
||||
);
|
||||
const statusClass = statusBadgeClass[normalizedStatus] ?? "bg-gray-100 text-gray-800";
|
||||
const containerClasses = embedded
|
||||
? ""
|
||||
: "bg-white shadow-lg rounded-xl border border-gray-100";
|
||||
const containerClasses = embedded ? "" : "bg-white shadow-lg rounded-xl border border-gray-100";
|
||||
|
||||
return (
|
||||
<div className={`${containerClasses} ${embedded ? "" : "p-6 lg:p-8"}`}>
|
||||
|
||||
@ -78,7 +78,7 @@ const getBillingCycleLabel = (cycle: string) => {
|
||||
export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps>(
|
||||
({ subscription, variant = "list", showActions = true, onViewClick, className }, ref) => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
|
||||
|
||||
const handleViewClick = () => {
|
||||
if (onViewClick) {
|
||||
onViewClick(subscription);
|
||||
@ -111,9 +111,7 @@ export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps
|
||||
<div className="grid grid-cols-2 gap-4 text-sm">
|
||||
<div>
|
||||
<p className="text-gray-500">Price</p>
|
||||
<p className="font-semibold text-gray-900">
|
||||
{formatCurrency(subscription.amount)}
|
||||
</p>
|
||||
<p className="font-semibold text-gray-900">{formatCurrency(subscription.amount)}</p>
|
||||
<p className="text-xs text-gray-500">{getBillingCycleLabel(subscription.cycle)}</p>
|
||||
</div>
|
||||
<div>
|
||||
@ -171,9 +169,7 @@ export const SubscriptionCard = forwardRef<HTMLDivElement, SubscriptionCardProps
|
||||
|
||||
<div className="flex items-center space-x-6 text-sm">
|
||||
<div className="text-right">
|
||||
<p className="font-semibold text-gray-900">
|
||||
{formatCurrency(subscription.amount)}
|
||||
</p>
|
||||
<p className="font-semibold text-gray-900">{formatCurrency(subscription.amount)}</p>
|
||||
<p className="text-gray-500">{getBillingCycleLabel(subscription.cycle)}</p>
|
||||
</div>
|
||||
|
||||
|
||||
@ -104,7 +104,7 @@ const isVpnService = (productName: string) => {
|
||||
export const SubscriptionDetails = forwardRef<HTMLDivElement, SubscriptionDetailsProps>(
|
||||
({ subscription, showServiceSpecificSections = true, className }, ref) => {
|
||||
const { formatCurrency } = useFormatCurrency();
|
||||
|
||||
|
||||
return (
|
||||
<div ref={ref} className={cn("space-y-6", className)}>
|
||||
{/* Main Details Card */}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user