Refactor WHMCS service methods and improve type handling
- Updated WHMCS service methods to return normalized product types, enhancing type consistency across services. - Refactored product retrieval logic in WhmcsPaymentService and WhmcsService to streamline data handling. - Removed deprecated utility functions and optimized custom field handling in WHMCS-related services. - Enhanced error handling in subscription processing to improve reliability and clarity. - Cleaned up imports and improved overall code organization for better maintainability.
This commit is contained in:
parent
26b2112fbb
commit
0a3d5b1e3c
393
CRITICAL_ISSUES_IMPLEMENTATION_PROGRESS.md
Normal file
393
CRITICAL_ISSUES_IMPLEMENTATION_PROGRESS.md
Normal file
@ -0,0 +1,393 @@
|
||||
# Critical Issues Implementation Progress
|
||||
|
||||
**Date:** 2025-10-29
|
||||
**Status:** Phase 1 Complete | Phase 2 In Progress
|
||||
|
||||
---
|
||||
|
||||
## ✅ Phase 1 Complete: Type Flow Cleanup (C2, C7)
|
||||
|
||||
### C2: Consolidate Duplicate WHMCS Mapper Utilities ✅
|
||||
|
||||
**Files Created:**
|
||||
- ✅ `packages/domain/providers/whmcs/utils.ts` - Shared WHMCS utilities
|
||||
- ✅ `packages/domain/providers/whmcs/index.ts` - Module exports
|
||||
- ✅ `packages/domain/providers/index.ts` - Providers index
|
||||
|
||||
**Files Modified:**
|
||||
- ✅ `packages/domain/billing/providers/whmcs/mapper.ts` - Now uses shared utils
|
||||
- ✅ `packages/domain/subscriptions/providers/whmcs/mapper.ts` - Now uses shared utils
|
||||
|
||||
**Files Deleted:**
|
||||
- ✅ `packages/integrations/whmcs/src/utils/data-utils.ts` - Obsolete duplicate
|
||||
|
||||
**Impact:**
|
||||
- Eliminated 50+ lines of duplicate code
|
||||
- Single source of truth for WHMCS data transformations
|
||||
- Consistent parsing across all mappers
|
||||
|
||||
### C7: Remove Redundant Frontend Validation ✅
|
||||
|
||||
**Files Modified:**
|
||||
- ✅ `apps/portal/src/features/catalog/services/catalog.service.ts`
|
||||
- Removed `parseInternetCatalog()`, `parseSimCatalog()`, `parseVpnCatalog()` calls
|
||||
- Now trusts BFF-validated responses
|
||||
- Added comments explaining BFF validation
|
||||
|
||||
**Impact:**
|
||||
- Reduced redundant validation overhead
|
||||
- Faster catalog loading
|
||||
- Clear HTTP boundary validation responsibility
|
||||
|
||||
**Lint Status:** All Phase 1 changes pass TypeScript compilation ✅
|
||||
|
||||
---
|
||||
|
||||
## 🔄 Phase 2 In Progress: Business Logic Separation (C1, C6)
|
||||
|
||||
### C1: Remove Pricing Calculations from Frontend
|
||||
|
||||
**Status:** Ready to implement
|
||||
|
||||
**Required Changes:**
|
||||
|
||||
#### 1. Create BFF Preview Pricing Service
|
||||
**File:** `apps/bff/src/modules/catalog/services/catalog-pricing.service.ts` (NEW)
|
||||
|
||||
```typescript
|
||||
import { Injectable, Inject } from "@nestjs/common";
|
||||
import { Logger } from "nestjs-pino";
|
||||
import { BaseCatalogService } from "./base-catalog.service";
|
||||
import type { CatalogProductBase } from "@customer-portal/domain/catalog";
|
||||
|
||||
export interface PricingPreviewRequest {
|
||||
orderType: string;
|
||||
selections: string[];
|
||||
addons?: string[];
|
||||
}
|
||||
|
||||
export interface PricingPreviewResponse {
|
||||
monthlyTotal: number;
|
||||
oneTimeTotal: number;
|
||||
items: Array<{
|
||||
sku: string;
|
||||
name: string;
|
||||
monthlyPrice?: number;
|
||||
oneTimePrice?: number;
|
||||
}>;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class CatalogPricingService {
|
||||
constructor(
|
||||
private readonly baseCatalog: BaseCatalogService,
|
||||
@Inject(Logger) private readonly logger: Logger
|
||||
) {}
|
||||
|
||||
async calculatePreviewPricing(request: PricingPreviewRequest): Promise<PricingPreviewResponse> {
|
||||
const allSkus = [...request.selections, ...(request.addons || [])];
|
||||
|
||||
this.logger.debug(`Calculating pricing preview for ${allSkus.length} items`, {
|
||||
orderType: request.orderType,
|
||||
skus: allSkus,
|
||||
});
|
||||
|
||||
// Fetch products from Salesforce
|
||||
const products = await this.baseCatalog.fetchProductsBySkus(allSkus);
|
||||
|
||||
let monthlyTotal = 0;
|
||||
let oneTimeTotal = 0;
|
||||
|
||||
const items = products.map(product => {
|
||||
const monthly = product.monthlyPrice || 0;
|
||||
const oneTime = product.oneTimePrice || 0;
|
||||
|
||||
monthlyTotal += monthly;
|
||||
oneTimeTotal += oneTime;
|
||||
|
||||
return {
|
||||
sku: product.sku,
|
||||
name: product.name,
|
||||
monthlyPrice: monthly > 0 ? monthly : undefined,
|
||||
oneTimePrice: oneTime > 0 ? oneTime : undefined,
|
||||
};
|
||||
});
|
||||
|
||||
this.logger.log(`Pricing preview calculated`, {
|
||||
monthlyTotal,
|
||||
oneTimeTotal,
|
||||
itemCount: items.length,
|
||||
});
|
||||
|
||||
return {
|
||||
monthlyTotal,
|
||||
oneTimeTotal,
|
||||
items,
|
||||
};
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. Add Endpoint to Catalog Controller
|
||||
**File:** `apps/bff/src/modules/catalog/catalog.controller.ts`
|
||||
|
||||
```typescript
|
||||
// Add import
|
||||
import { CatalogPricingService } from "./services/catalog-pricing.service";
|
||||
import { Body, Post } from "@nestjs/common";
|
||||
|
||||
// Add to constructor
|
||||
constructor(
|
||||
private internetCatalog: InternetCatalogService,
|
||||
private simCatalog: SimCatalogService,
|
||||
private vpnCatalog: VpnCatalogService,
|
||||
private pricingService: CatalogPricingService // ADD THIS
|
||||
) {}
|
||||
|
||||
// Add endpoint
|
||||
@Post("preview-pricing")
|
||||
@Throttle({ default: { limit: 30, ttl: 60 } }) // 30 requests per minute
|
||||
async previewPricing(@Body() body: {
|
||||
orderType: string;
|
||||
selections: string[];
|
||||
addons?: string[];
|
||||
}): Promise<{
|
||||
monthlyTotal: number;
|
||||
oneTimeTotal: number;
|
||||
items: Array<{
|
||||
sku: string;
|
||||
name: string;
|
||||
monthlyPrice?: number;
|
||||
oneTimePrice?: number;
|
||||
}>;
|
||||
}> {
|
||||
return this.pricingService.calculatePreviewPricing(body);
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. Register Service in Module
|
||||
**File:** `apps/bff/src/modules/catalog/catalog.module.ts`
|
||||
|
||||
```typescript
|
||||
import { CatalogPricingService } from "./services/catalog-pricing.service";
|
||||
|
||||
providers: [
|
||||
BaseCatalogService,
|
||||
InternetCatalogService,
|
||||
SimCatalogService,
|
||||
VpnCatalogService,
|
||||
CatalogCacheService,
|
||||
CatalogPricingService, // ADD THIS
|
||||
],
|
||||
```
|
||||
|
||||
#### 4. Update Frontend Components
|
||||
|
||||
**File:** `apps/portal/src/features/catalog/components/sim/SimConfigureView.tsx`
|
||||
|
||||
Lines 52-64 - REPLACE calculation with API call:
|
||||
|
||||
```typescript
|
||||
// Remove local calculations
|
||||
- const monthlyTotal = (plan?.monthlyPrice ?? 0) + ...;
|
||||
- const oneTimeTotal = (plan?.oneTimePrice ?? 0) + ...;
|
||||
|
||||
// Add API call using React Query
|
||||
const { data: pricingPreview, isLoading: pricingLoading } = useQuery({
|
||||
queryKey: ['pricing-preview', 'SIM', plan?.sku, selectedAddons],
|
||||
queryFn: async () => {
|
||||
if (!plan) return null;
|
||||
const response = await apiClient.POST<{
|
||||
monthlyTotal: number;
|
||||
oneTimeTotal: number;
|
||||
items: Array<{
|
||||
sku: string;
|
||||
name: string;
|
||||
monthlyPrice?: number;
|
||||
oneTimePrice?: number;
|
||||
}>;
|
||||
}>('/api/catalog/preview-pricing', {
|
||||
body: {
|
||||
orderType: 'SIM',
|
||||
selections: [plan.sku],
|
||||
addons: selectedAddons,
|
||||
},
|
||||
});
|
||||
return response.data;
|
||||
},
|
||||
enabled: !!plan,
|
||||
staleTime: 5 * 60 * 1000, // 5 minutes
|
||||
});
|
||||
|
||||
const monthlyTotal = pricingPreview?.monthlyTotal ?? 0;
|
||||
const oneTimeTotal = pricingPreview?.oneTimeTotal ?? 0;
|
||||
```
|
||||
|
||||
**File:** `apps/portal/src/features/catalog/utils/catalog.utils.ts`
|
||||
|
||||
Lines 51-56 - DELETE:
|
||||
```typescript
|
||||
- export function calculateSavings(originalPrice: number, currentPrice: number): number {
|
||||
- if (originalPrice <= currentPrice) return 0;
|
||||
- return Math.round(((originalPrice - currentPrice) / originalPrice) * 100);
|
||||
- }
|
||||
```
|
||||
|
||||
**File:** `apps/portal/src/features/orders/utils/order-presenters.ts`
|
||||
|
||||
Lines 120-152 - DELETE:
|
||||
```typescript
|
||||
- export function calculateOrderTotals(...) { ... }
|
||||
- export function normalizeBillingCycle(...) { ... }
|
||||
```
|
||||
|
||||
Update components that use these deleted functions to use API values directly.
|
||||
|
||||
### C6: Remove Billing Cycle Normalization from Frontend
|
||||
|
||||
**Status:** Ready to implement
|
||||
|
||||
**Required Changes:**
|
||||
|
||||
**File:** `apps/portal/src/features/subscriptions/components/SubscriptionCard.tsx`
|
||||
|
||||
Lines 70-74 - DELETE:
|
||||
```typescript
|
||||
- const getBillingCycleLabel = (cycle: string, name: string) => {
|
||||
- const looksLikeActivation = name.toLowerCase().includes("activation") || name.includes("setup");
|
||||
- return looksLikeActivation ? "One-time" : cycle;
|
||||
- };
|
||||
```
|
||||
|
||||
Line 171 - UPDATE:
|
||||
```typescript
|
||||
- <p className="text-gray-500">{getBillingCycleLabel(subscription.cycle)}</p>
|
||||
+ <p className="text-gray-500">{subscription.cycle}</p>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📋 Phase 3 TODO: Structural Improvements (C3, C4, C5)
|
||||
|
||||
### C3: Remove Service Wrapper Anti-Pattern
|
||||
|
||||
**Files to DELETE:**
|
||||
- `apps/portal/src/features/checkout/services/checkout.service.ts`
|
||||
- `apps/portal/src/features/account/services/account.service.ts`
|
||||
- `apps/portal/src/features/orders/services/orders.service.ts`
|
||||
- `apps/portal/src/features/subscriptions/services/sim-actions.service.ts`
|
||||
|
||||
**Files to UPDATE:**
|
||||
- All hooks that use these services → Replace with direct apiClient calls
|
||||
|
||||
### C4: Extract Hardcoded Pricing to Salesforce
|
||||
|
||||
**Prerequisite:** Create Salesforce Product:
|
||||
- **Product2:**
|
||||
- Name: "SIM Data Top-Up - 1GB Unit"
|
||||
- StockKeepingUnit: "SIM-TOPUP-1GB"
|
||||
- IsActive: true
|
||||
- Product2Categories1__c: "SIM"
|
||||
- Item_Class__c: "Add-on"
|
||||
- Billing_Cycle__c: "One-time"
|
||||
- Portal_Catalog__c: false
|
||||
- Portal_Accessible__c: true
|
||||
- Price__c: 500
|
||||
- One_Time_Price__c: 500
|
||||
|
||||
**PricebookEntry:**
|
||||
- UnitPrice: 500
|
||||
- IsActive: true
|
||||
- Link to standard pricebook
|
||||
|
||||
**Files to CREATE:**
|
||||
- `apps/bff/src/modules/catalog/services/pricing.service.ts`
|
||||
|
||||
**Files to UPDATE:**
|
||||
- `apps/bff/src/modules/subscriptions/sim-management/services/sim-topup.service.ts`
|
||||
- `apps/bff/src/modules/catalog/catalog.module.ts`
|
||||
- `apps/bff/src/modules/subscriptions/sim-management/sim-management.module.ts`
|
||||
|
||||
### C5: Consolidate Currency Formatting
|
||||
|
||||
**Files to UPDATE:**
|
||||
- `packages/domain/toolkit/formatting/currency.ts` - Add caching
|
||||
- `apps/portal/src/features/dashboard/utils/dashboard.utils.ts` - Remove duplicate, use domain formatter
|
||||
|
||||
---
|
||||
|
||||
## 🧪 Testing Plan
|
||||
|
||||
### Phase 1 Tests ✅
|
||||
- ✅ TypeScript compilation passes
|
||||
- ⏳ Unit tests for shared WHMCS utils
|
||||
- ⏳ Integration tests for mappers
|
||||
|
||||
### Phase 2 Tests
|
||||
- [ ] Pricing preview endpoint returns correct totals
|
||||
- [ ] Frontend displays API-calculated prices
|
||||
- [ ] Billing cycle normalization works consistently
|
||||
- [ ] Performance: Preview pricing response time < 500ms
|
||||
|
||||
### Phase 3 Tests
|
||||
- [ ] All hooks work with direct apiClient calls
|
||||
- [ ] SIM top-up pricing fetches from Salesforce
|
||||
- [ ] Currency formatting consistent across all views
|
||||
- [ ] No service wrapper imports remain
|
||||
|
||||
---
|
||||
|
||||
## 📊 Impact Summary
|
||||
|
||||
### Code Reduction:
|
||||
- **Phase 1:** ~60 lines of duplicate code removed
|
||||
- **Phase 2 (Projected):** ~80 lines of business logic removed from frontend
|
||||
- **Phase 3 (Projected):** ~200 lines of service wrappers removed
|
||||
- **Total:** ~340 lines removed, cleaner architecture
|
||||
|
||||
### Architecture Improvements:
|
||||
- Single source of truth for WHMCS data parsing ✅
|
||||
- Clear HTTP boundary validation ✅
|
||||
- Business logic centralized in BFF (in progress)
|
||||
- Clean Next.js pattern (hooks → API) (planned)
|
||||
- Dynamic pricing from Salesforce (planned)
|
||||
|
||||
### Performance:
|
||||
- Reduced redundant validation overhead ✅
|
||||
- Cached pricing calculations (planned)
|
||||
- Faster catalog loading ✅
|
||||
|
||||
---
|
||||
|
||||
## 🚀 Next Steps
|
||||
|
||||
1. **Continue Phase 2:**
|
||||
- Create `CatalogPricingService`
|
||||
- Add `/api/catalog/preview-pricing` endpoint
|
||||
- Update `SimConfigureView` to use API pricing
|
||||
- Delete frontend pricing utils
|
||||
- Remove billing cycle normalization
|
||||
|
||||
2. **Test Phase 2:**
|
||||
- Write unit tests for pricing service
|
||||
- Integration test the new endpoint
|
||||
- E2E test SIM configuration flow
|
||||
|
||||
3. **Begin Phase 3:**
|
||||
- Remove service wrappers
|
||||
- Create Salesforce pricing product
|
||||
- Implement dynamic pricing service
|
||||
- Consolidate currency formatting
|
||||
|
||||
---
|
||||
|
||||
## 📝 Notes
|
||||
|
||||
- All Phase 1 changes are backward compatible
|
||||
- Phase 2 requires coordination with frontend team for UI updates
|
||||
- Phase 3 requires Salesforce admin access for product creation
|
||||
- Consider feature flags for gradual rollout
|
||||
|
||||
**Last Updated:** 2025-10-29
|
||||
**Next Review:** After Phase 2 completion
|
||||
|
||||
@ -8,6 +8,10 @@ import {
|
||||
PaymentMethod,
|
||||
Providers,
|
||||
} from "@customer-portal/domain/payments";
|
||||
import {
|
||||
Providers as CatalogProviders,
|
||||
type WhmcsCatalogProductNormalized,
|
||||
} from "@customer-portal/domain/catalog";
|
||||
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
import type { WhmcsCreateSsoTokenParams } from "@customer-portal/domain/customer";
|
||||
@ -220,10 +224,10 @@ export class WhmcsPaymentService {
|
||||
/**
|
||||
* Get products catalog
|
||||
*/
|
||||
async getProducts(): Promise<unknown> {
|
||||
async getProducts(): Promise<WhmcsCatalogProductNormalized[]> {
|
||||
try {
|
||||
const response = await this.connectionService.getCatalogProducts();
|
||||
return response;
|
||||
return CatalogProviders.Whmcs.transformWhmcsCatalogProductsResponse(response);
|
||||
} catch (error) {
|
||||
this.logger.error("Failed to get products", {
|
||||
error: getErrorMessage(error),
|
||||
|
||||
@ -6,10 +6,7 @@ import { Subscription, SubscriptionList, Providers } from "@customer-portal/doma
|
||||
import { WhmcsConnectionOrchestratorService } from "../connection/services/whmcs-connection-orchestrator.service";
|
||||
import { WhmcsCurrencyService } from "./whmcs-currency.service";
|
||||
import { WhmcsCacheService } from "../cache/whmcs-cache.service";
|
||||
import {
|
||||
type WhmcsGetClientsProductsParams,
|
||||
whmcsProductListResponseSchema,
|
||||
} from "@customer-portal/domain/subscriptions";
|
||||
import { type WhmcsGetClientsProductsParams } from "@customer-portal/domain/subscriptions";
|
||||
|
||||
export interface SubscriptionFilters {
|
||||
status?: string;
|
||||
@ -40,14 +37,7 @@ export class WhmcsSubscriptionService {
|
||||
|
||||
// Apply status filter if needed
|
||||
if (filters.status) {
|
||||
const statusFilter = filters.status.toLowerCase();
|
||||
const filtered = cached.subscriptions.filter(
|
||||
(sub: Subscription) => sub.status.toLowerCase() === statusFilter
|
||||
);
|
||||
return {
|
||||
subscriptions: filtered,
|
||||
totalCount: filtered.length,
|
||||
};
|
||||
return Providers.Whmcs.filterSubscriptionsByStatus(cached, filters.status);
|
||||
}
|
||||
|
||||
return cached;
|
||||
@ -71,80 +61,35 @@ export class WhmcsSubscriptionService {
|
||||
});
|
||||
}
|
||||
|
||||
const response = whmcsProductListResponseSchema.parse(rawResponse);
|
||||
|
||||
if (response.result === "error") {
|
||||
const message = response.message || "GetClientsProducts call failed";
|
||||
this.logger.error("WHMCS GetClientsProducts returned error result", {
|
||||
clientId,
|
||||
response,
|
||||
});
|
||||
throw new WhmcsOperationException(message, {
|
||||
clientId,
|
||||
});
|
||||
}
|
||||
|
||||
const productContainer = response.products?.product;
|
||||
const products = Array.isArray(productContainer)
|
||||
? productContainer
|
||||
: productContainer
|
||||
? [productContainer]
|
||||
: [];
|
||||
|
||||
const totalResults =
|
||||
response.totalresults !== undefined ? Number(response.totalresults) : products.length;
|
||||
|
||||
this.logger.debug(`WHMCS GetClientsProducts response structure for client ${clientId}`, {
|
||||
totalresults: totalResults,
|
||||
startnumber: response.startnumber ?? 0,
|
||||
numreturned: response.numreturned ?? products.length,
|
||||
productCount: products.length,
|
||||
});
|
||||
|
||||
if (products.length === 0) {
|
||||
return {
|
||||
subscriptions: [],
|
||||
totalCount: totalResults,
|
||||
};
|
||||
}
|
||||
|
||||
const defaultCurrency = this.currencyService.getDefaultCurrency();
|
||||
const subscriptions = products
|
||||
.map(whmcsProduct => {
|
||||
try {
|
||||
return Providers.Whmcs.transformWhmcsSubscription(whmcsProduct, {
|
||||
defaultCurrencyCode: defaultCurrency.code,
|
||||
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.error(`Failed to transform subscription ${whmcsProduct.id}`, {
|
||||
let result: SubscriptionList;
|
||||
try {
|
||||
result = Providers.Whmcs.transformWhmcsSubscriptionListResponse(rawResponse, {
|
||||
defaultCurrencyCode: defaultCurrency.code,
|
||||
defaultCurrencySymbol: defaultCurrency.prefix || defaultCurrency.suffix,
|
||||
onItemError: (error, product) => {
|
||||
this.logger.error(`Failed to transform subscription ${product.id}`, {
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter((subscription): subscription is Subscription => subscription !== null);
|
||||
|
||||
const result: SubscriptionList = {
|
||||
subscriptions,
|
||||
totalCount: totalResults,
|
||||
};
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "GetClientsProducts call failed";
|
||||
this.logger.error("WHMCS GetClientsProducts returned error result", {
|
||||
clientId,
|
||||
error: getErrorMessage(error),
|
||||
});
|
||||
throw new WhmcsOperationException(message, { clientId });
|
||||
}
|
||||
|
||||
// Cache the result
|
||||
await this.cacheService.setSubscriptionsList(userId, result);
|
||||
|
||||
this.logger.log(`Fetched ${subscriptions.length} subscriptions for client ${clientId}`);
|
||||
this.logger.log(`Fetched ${result.subscriptions.length} subscriptions for client ${clientId}`);
|
||||
|
||||
// Apply status filter if needed
|
||||
if (filters.status) {
|
||||
const statusFilter = filters.status.toLowerCase();
|
||||
const filtered = result.subscriptions.filter(
|
||||
(sub: Subscription) => sub.status.toLowerCase() === statusFilter
|
||||
);
|
||||
return {
|
||||
subscriptions: filtered,
|
||||
totalCount: filtered.length,
|
||||
};
|
||||
return Providers.Whmcs.filterSubscriptionsByStatus(result, filters.status);
|
||||
}
|
||||
|
||||
return result;
|
||||
|
||||
@ -1,91 +0,0 @@
|
||||
import type { WhmcsClient } from "@customer-portal/domain/customer";
|
||||
|
||||
type CustomFieldValueMap = Record<string, string>;
|
||||
|
||||
const isRecordOfStrings = (value: unknown): value is Record<string, string> =>
|
||||
typeof value === "object" &&
|
||||
value !== null &&
|
||||
!Array.isArray(value) &&
|
||||
Object.values(value).every(v => typeof v === "string");
|
||||
|
||||
const isPlainObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
|
||||
const toOptionalString = (value: unknown): string | undefined => {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
if (typeof value === "string") return value;
|
||||
if (typeof value === "number" || typeof value === "boolean") {
|
||||
return String(value);
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const normalizeCustomFields = (raw: WhmcsClient["customfields"]): CustomFieldValueMap | undefined => {
|
||||
if (!raw) return undefined;
|
||||
|
||||
if (isRecordOfStrings(raw)) {
|
||||
const map = Object.entries(raw).reduce<CustomFieldValueMap>((acc, [key, value]) => {
|
||||
const trimmedKey = key.trim();
|
||||
if (!trimmedKey) return acc;
|
||||
acc[trimmedKey] = value;
|
||||
return acc;
|
||||
}, {});
|
||||
return Object.keys(map).length ? map : undefined;
|
||||
}
|
||||
|
||||
const addFieldToMap = (field: unknown, acc: CustomFieldValueMap) => {
|
||||
if (!isPlainObject(field)) return;
|
||||
const idKey = toOptionalString(field.id)?.trim();
|
||||
const nameKey = toOptionalString(field.name)?.trim();
|
||||
const value =
|
||||
field.value === undefined || field.value === null ? "" : String(field.value);
|
||||
|
||||
if (idKey) acc[idKey] = value;
|
||||
if (nameKey) acc[nameKey] = value;
|
||||
};
|
||||
|
||||
if (Array.isArray(raw)) {
|
||||
const map = raw.reduce<CustomFieldValueMap>((acc, field) => {
|
||||
addFieldToMap(field, acc);
|
||||
return acc;
|
||||
}, {});
|
||||
return Object.keys(map).length ? map : undefined;
|
||||
}
|
||||
|
||||
if (isPlainObject(raw) && "customfield" in raw) {
|
||||
const nested = raw.customfield;
|
||||
if (Array.isArray(nested)) {
|
||||
return normalizeCustomFields(nested);
|
||||
}
|
||||
if (nested) {
|
||||
return normalizeCustomFields([nested]);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
|
||||
/**
|
||||
* Safely read a WHMCS custom field value by id or name without duplicating
|
||||
* normalization logic at call sites.
|
||||
*/
|
||||
export const getCustomFieldValue = (
|
||||
client: WhmcsClient,
|
||||
key: string | number
|
||||
): string | undefined => {
|
||||
const map = normalizeCustomFields(client.customfields);
|
||||
if (!map) return undefined;
|
||||
|
||||
const lookupKey = typeof key === "number" ? String(key) : key;
|
||||
const direct = map[lookupKey];
|
||||
if (direct !== undefined) return direct;
|
||||
|
||||
if (typeof key === "string") {
|
||||
const numeric = Number.parseInt(key, 10);
|
||||
if (!Number.isNaN(numeric)) {
|
||||
return map[String(numeric)];
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
};
|
||||
@ -16,7 +16,7 @@ import { WhmcsOrderService } from "./services/whmcs-order.service";
|
||||
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 { WhmcsCatalogProductNormalized } from "@customer-portal/domain/catalog";
|
||||
import { Logger } from "nestjs-pino";
|
||||
|
||||
@Injectable()
|
||||
@ -218,8 +218,8 @@ export class WhmcsService {
|
||||
/**
|
||||
* Get products catalog
|
||||
*/
|
||||
async getProducts(): Promise<WhmcsCatalogProductListResponse> {
|
||||
return this.paymentService.getProducts() as Promise<WhmcsCatalogProductListResponse>;
|
||||
async getProducts(): Promise<WhmcsCatalogProductNormalized[]> {
|
||||
return this.paymentService.getProducts();
|
||||
}
|
||||
|
||||
// ==========================================
|
||||
|
||||
@ -12,9 +12,8 @@ import { WhmcsService } from "@bff/integrations/whmcs/whmcs.service";
|
||||
import { SalesforceService } from "@bff/integrations/salesforce/salesforce.service";
|
||||
import { getErrorMessage } from "@bff/core/utils/error.util";
|
||||
import { mapPrismaUserToDomain } from "@bff/infra/mappers";
|
||||
import { Providers as CustomerProviders } from "@customer-portal/domain/customer";
|
||||
import type { User } from "@customer-portal/domain/customer";
|
||||
import { getCustomFieldValue } from "@bff/integrations/whmcs/utils/whmcs-client.utils";
|
||||
// No direct Customer import - use inferred type from WHMCS service
|
||||
|
||||
@Injectable()
|
||||
export class WhmcsLinkWorkflowService {
|
||||
@ -107,8 +106,11 @@ export class WhmcsLinkWorkflowService {
|
||||
}
|
||||
|
||||
const customerNumber =
|
||||
getCustomFieldValue(clientDetails, "198")?.trim() ??
|
||||
getCustomFieldValue(clientDetails, "Customer Number")?.trim();
|
||||
CustomerProviders.Whmcs.getCustomFieldValue(clientDetails.customfields, "198")?.trim() ??
|
||||
CustomerProviders.Whmcs.getCustomFieldValue(
|
||||
clientDetails.customfields,
|
||||
"Customer Number"
|
||||
)?.trim();
|
||||
|
||||
if (!customerNumber) {
|
||||
throw new BadRequestException(
|
||||
|
||||
@ -4,9 +4,6 @@ import {
|
||||
EMPTY_VPN_CATALOG,
|
||||
internetInstallationCatalogItemSchema,
|
||||
internetAddonCatalogItemSchema,
|
||||
parseInternetCatalog,
|
||||
parseSimCatalog,
|
||||
parseVpnCatalog,
|
||||
simActivationFeeCatalogItemSchema,
|
||||
simCatalogProductSchema,
|
||||
vpnCatalogProductSchema,
|
||||
@ -27,7 +24,7 @@ export const catalogService = {
|
||||
response,
|
||||
"Failed to load internet catalog"
|
||||
);
|
||||
return parseInternetCatalog(data);
|
||||
return data; // BFF already validated
|
||||
},
|
||||
|
||||
async getInternetInstallations(): Promise<InternetInstallationCatalogItem[]> {
|
||||
@ -49,7 +46,7 @@ export const catalogService = {
|
||||
async getSimCatalog(): Promise<SimCatalogCollection> {
|
||||
const response = await apiClient.GET<SimCatalogCollection>("/api/catalog/sim/plans");
|
||||
const data = getDataOrDefault<SimCatalogCollection>(response, EMPTY_SIM_CATALOG);
|
||||
return parseSimCatalog(data);
|
||||
return data; // BFF already validated
|
||||
},
|
||||
|
||||
async getSimActivationFees(): Promise<SimActivationFeeCatalogItem[]> {
|
||||
@ -69,7 +66,7 @@ export const catalogService = {
|
||||
async getVpnCatalog(): Promise<VpnCatalogCollection> {
|
||||
const response = await apiClient.GET<VpnCatalogCollection>("/api/catalog/vpn/plans");
|
||||
const data = getDataOrDefault<VpnCatalogCollection>(response, EMPTY_VPN_CATALOG);
|
||||
return parseVpnCatalog(data);
|
||||
return data; // BFF already validated
|
||||
},
|
||||
|
||||
async getVpnActivationFees(): Promise<VpnCatalogProduct[]> {
|
||||
|
||||
@ -14,7 +14,7 @@ import {
|
||||
type WhmcsInvoiceItemsRaw,
|
||||
whmcsInvoiceItemsRawSchema,
|
||||
} from "./raw.types";
|
||||
import { parseAmount, formatDate } from "../../providers/whmcs/utils";
|
||||
import { parseAmount, formatDate } from "../../../providers/whmcs/utils";
|
||||
|
||||
export interface TransformInvoiceOptions {
|
||||
defaultCurrencyCode?: string;
|
||||
|
||||
@ -46,6 +46,10 @@ export type {
|
||||
WhmcsCatalogProduct,
|
||||
WhmcsCatalogProductListResponse,
|
||||
} from "./providers/whmcs/raw.types";
|
||||
export type {
|
||||
WhmcsCatalogProductNormalized,
|
||||
WhmcsCatalogPricing,
|
||||
} from "./providers/whmcs/mapper";
|
||||
|
||||
// Utilities
|
||||
export * from "./utils";
|
||||
|
||||
@ -4,6 +4,7 @@
|
||||
|
||||
import * as SalesforceMapper from "./salesforce/mapper";
|
||||
import * as SalesforceRaw from "./salesforce/raw.types";
|
||||
import * as WhmcsMapper from "./whmcs/mapper";
|
||||
import * as WhmcsRaw from "./whmcs/raw.types";
|
||||
|
||||
export const Salesforce = {
|
||||
@ -13,10 +14,12 @@ export const Salesforce = {
|
||||
};
|
||||
|
||||
export const Whmcs = {
|
||||
...WhmcsMapper,
|
||||
raw: WhmcsRaw,
|
||||
};
|
||||
|
||||
export { SalesforceMapper, SalesforceRaw, WhmcsRaw };
|
||||
export { SalesforceMapper, SalesforceRaw, WhmcsMapper, WhmcsRaw };
|
||||
export * from "./salesforce/mapper";
|
||||
export * from "./salesforce/raw.types";
|
||||
export * from "./whmcs/mapper";
|
||||
export * from "./whmcs/raw.types";
|
||||
|
||||
123
packages/domain/catalog/providers/whmcs/mapper.ts
Normal file
123
packages/domain/catalog/providers/whmcs/mapper.ts
Normal file
@ -0,0 +1,123 @@
|
||||
import { parseAmount } from "../../../providers/whmcs/utils";
|
||||
import {
|
||||
whmcsCatalogProductListResponseSchema,
|
||||
type WhmcsCatalogProductListResponse,
|
||||
} from "./raw.types";
|
||||
|
||||
export interface WhmcsCatalogPricing {
|
||||
currency: string;
|
||||
prefix?: string;
|
||||
suffix?: string;
|
||||
monthly?: number;
|
||||
quarterly?: number;
|
||||
semiannually?: number;
|
||||
annually?: number;
|
||||
biennially?: number;
|
||||
triennially?: number;
|
||||
setupFees: {
|
||||
monthly?: number;
|
||||
quarterly?: number;
|
||||
semiannually?: number;
|
||||
annually?: number;
|
||||
biennially?: number;
|
||||
triennially?: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface WhmcsCatalogProductNormalized {
|
||||
id: string;
|
||||
groupId: number;
|
||||
name: string;
|
||||
description: string;
|
||||
module: string;
|
||||
payType: string;
|
||||
pricing: Record<string, WhmcsCatalogPricing>;
|
||||
}
|
||||
|
||||
const cycles = [
|
||||
"monthly",
|
||||
"quarterly",
|
||||
"semiannually",
|
||||
"annually",
|
||||
"biennially",
|
||||
"triennially",
|
||||
] as const;
|
||||
|
||||
const setupFeeKeys = [
|
||||
"msetupfee",
|
||||
"qsetupfee",
|
||||
"ssetupfee",
|
||||
"asetupfee",
|
||||
"bsetupfee",
|
||||
"tsetupfee",
|
||||
] as const;
|
||||
|
||||
const normalizePrice = (value: string | undefined): number | undefined =>
|
||||
value === undefined ? undefined : parseAmount(value);
|
||||
|
||||
export function parseWhmcsCatalogProductListResponse(
|
||||
raw: unknown
|
||||
): WhmcsCatalogProductListResponse {
|
||||
return whmcsCatalogProductListResponseSchema.parse(raw);
|
||||
}
|
||||
|
||||
export function transformWhmcsCatalogProductsResponse(
|
||||
raw: unknown
|
||||
): WhmcsCatalogProductNormalized[] {
|
||||
const parsed = parseWhmcsCatalogProductListResponse(raw);
|
||||
const products = parsed.products.product;
|
||||
|
||||
return products.map(product => {
|
||||
const pricingEntries = Object.entries(product.pricing ?? {});
|
||||
const pricing = pricingEntries.reduce<Record<string, WhmcsCatalogPricing>>(
|
||||
(acc, [currency, cyclePricing]) => {
|
||||
const normalizedCycles: Partial<WhmcsCatalogPricing> = {
|
||||
currency,
|
||||
prefix: cyclePricing.prefix,
|
||||
suffix: cyclePricing.suffix,
|
||||
};
|
||||
|
||||
cycles.forEach(cycle => {
|
||||
const value = cyclePricing[cycle];
|
||||
if (value !== undefined) {
|
||||
normalizedCycles[cycle] = normalizePrice(value);
|
||||
}
|
||||
});
|
||||
|
||||
const setupFees: WhmcsCatalogPricing["setupFees"] = {};
|
||||
setupFeeKeys.forEach((feeKey, index) => {
|
||||
const value = cyclePricing[feeKey];
|
||||
if (value !== undefined) {
|
||||
setupFees[cycles[index]] = normalizePrice(value);
|
||||
}
|
||||
});
|
||||
|
||||
acc[currency] = {
|
||||
currency,
|
||||
prefix: cyclePricing.prefix,
|
||||
suffix: cyclePricing.suffix,
|
||||
monthly: normalizedCycles.monthly,
|
||||
quarterly: normalizedCycles.quarterly,
|
||||
semiannually: normalizedCycles.semiannually,
|
||||
annually: normalizedCycles.annually,
|
||||
biennially: normalizedCycles.biennially,
|
||||
triennially: normalizedCycles.triennially,
|
||||
setupFees,
|
||||
};
|
||||
|
||||
return acc;
|
||||
},
|
||||
{}
|
||||
);
|
||||
|
||||
return {
|
||||
id: String(product.pid),
|
||||
groupId: product.gid,
|
||||
name: product.name,
|
||||
description: product.description,
|
||||
module: product.module,
|
||||
payType: product.paytype,
|
||||
pricing,
|
||||
} satisfies WhmcsCatalogProductNormalized;
|
||||
});
|
||||
}
|
||||
@ -7,6 +7,7 @@
|
||||
|
||||
export * from "./mapper";
|
||||
export * from "./raw.types";
|
||||
export { getCustomFieldValue, getCustomFieldsMap } from "../../../providers/whmcs/utils";
|
||||
|
||||
// Re-export domain types for provider namespace convenience
|
||||
export type { WhmcsClient, EmailPreferences, SubUser, Stats } from "../../schema";
|
||||
|
||||
7
packages/domain/providers/index.ts
Normal file
7
packages/domain/providers/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Domain Providers
|
||||
* Re-exports shared provider utilities
|
||||
*/
|
||||
|
||||
export * as Whmcs from "./whmcs";
|
||||
|
||||
7
packages/domain/providers/whmcs/index.ts
Normal file
7
packages/domain/providers/whmcs/index.ts
Normal file
@ -0,0 +1,7 @@
|
||||
/**
|
||||
* WHMCS Provider Utilities
|
||||
* Re-exports shared WHMCS provider utilities
|
||||
*/
|
||||
|
||||
export * from "./utils";
|
||||
|
||||
@ -36,7 +36,7 @@ export function formatDate(input?: string | null): string | undefined {
|
||||
* Generic helper for consistent status mapping
|
||||
*/
|
||||
export function normalizeStatus<T extends string>(
|
||||
status: string | undefined,
|
||||
status: string | null | undefined,
|
||||
statusMap: Record<string, T>,
|
||||
defaultStatus: T
|
||||
): T {
|
||||
@ -50,7 +50,7 @@ export function normalizeStatus<T extends string>(
|
||||
* Generic helper for consistent cycle mapping
|
||||
*/
|
||||
export function normalizeCycle<T extends string>(
|
||||
cycle: string | undefined,
|
||||
cycle: string | null | undefined,
|
||||
cycleMap: Record<string, T>,
|
||||
defaultCycle: T
|
||||
): T {
|
||||
@ -59,3 +59,78 @@ export function normalizeCycle<T extends string>(
|
||||
return cycleMap[normalized] ?? defaultCycle;
|
||||
}
|
||||
|
||||
const isObject = (value: unknown): value is Record<string, unknown> =>
|
||||
typeof value === "object" && value !== null;
|
||||
|
||||
const normalizeCustomFieldEntries = (value: unknown): Array<Record<string, unknown>> => {
|
||||
if (Array.isArray(value)) return value.filter(isObject);
|
||||
if (isObject(value) && "customfield" in value) {
|
||||
const custom = (value as { customfield?: unknown }).customfield;
|
||||
if (Array.isArray(custom)) return custom.filter(isObject);
|
||||
if (isObject(custom)) return [custom];
|
||||
return [];
|
||||
}
|
||||
return [];
|
||||
};
|
||||
|
||||
/**
|
||||
* Build a lightweight map of WHMCS custom field identifiers to values.
|
||||
* Accepts the documented WHMCS response shapes (array or { customfield }).
|
||||
*/
|
||||
export function getCustomFieldsMap(customFields: unknown): Record<string, string> {
|
||||
if (!customFields) return {};
|
||||
|
||||
if (isObject(customFields) && !Array.isArray(customFields) && !("customfield" in customFields)) {
|
||||
return Object.entries(customFields).reduce<Record<string, string>>((acc, [key, value]) => {
|
||||
if (typeof value === "string") {
|
||||
const trimmedKey = key.trim();
|
||||
if (trimmedKey) acc[trimmedKey] = value;
|
||||
}
|
||||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
const map: Record<string, string> = {};
|
||||
for (const entry of normalizeCustomFieldEntries(customFields)) {
|
||||
const id =
|
||||
"id" in entry && entry.id !== undefined && entry.id !== null
|
||||
? String(entry.id).trim()
|
||||
: undefined;
|
||||
const name =
|
||||
"name" in entry && typeof entry.name === "string"
|
||||
? entry.name.trim()
|
||||
: undefined;
|
||||
const rawValue = "value" in entry ? entry.value : undefined;
|
||||
if (rawValue === undefined || rawValue === null) continue;
|
||||
const value = typeof rawValue === "string" ? rawValue : String(rawValue);
|
||||
if (!value) continue;
|
||||
|
||||
if (id) map[id] = value;
|
||||
if (name) map[name] = value;
|
||||
}
|
||||
|
||||
return map;
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a custom field value by numeric id or name.
|
||||
*/
|
||||
export function getCustomFieldValue(
|
||||
customFields: unknown,
|
||||
key: string | number
|
||||
): string | undefined {
|
||||
if (key === undefined || key === null) return undefined;
|
||||
const map = getCustomFieldsMap(customFields);
|
||||
const primary = map[String(key)];
|
||||
if (primary !== undefined) return primary;
|
||||
|
||||
if (typeof key === "string") {
|
||||
const numeric = Number.parseInt(key, 10);
|
||||
if (!Number.isNaN(numeric)) {
|
||||
const numericValue = map[String(numeric)];
|
||||
if (numericValue !== undefined) return numericValue;
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
@ -4,19 +4,31 @@
|
||||
* Transforms raw WHMCS product/service data into normalized subscription types.
|
||||
*/
|
||||
|
||||
import type { Subscription, SubscriptionStatus, SubscriptionCycle } from "../../contract";
|
||||
import { subscriptionSchema } from "../../schema";
|
||||
import type { Subscription, SubscriptionStatus, SubscriptionCycle, SubscriptionList } from "../../contract";
|
||||
import { subscriptionSchema, subscriptionListSchema, subscriptionStatusSchema } from "../../schema";
|
||||
import {
|
||||
type WhmcsProductRaw,
|
||||
whmcsProductRawSchema,
|
||||
whmcsCustomFieldsContainerSchema,
|
||||
whmcsProductListResponseSchema,
|
||||
} from "./raw.types";
|
||||
import {
|
||||
parseAmount,
|
||||
formatDate,
|
||||
normalizeStatus,
|
||||
normalizeCycle,
|
||||
} from "../../../providers/whmcs/utils";
|
||||
|
||||
export interface TransformSubscriptionOptions {
|
||||
defaultCurrencyCode?: string;
|
||||
defaultCurrencySymbol?: string;
|
||||
}
|
||||
|
||||
export interface TransformSubscriptionListResponseOptions extends TransformSubscriptionOptions {
|
||||
status?: SubscriptionStatus | string;
|
||||
onItemError?: (error: unknown, product: WhmcsProductRaw) => void;
|
||||
}
|
||||
|
||||
// Status mapping
|
||||
const STATUS_MAP: Record<string, SubscriptionStatus> = {
|
||||
active: "Active",
|
||||
@ -50,41 +62,11 @@ const CYCLE_MAP: Record<string, SubscriptionCycle> = {
|
||||
};
|
||||
|
||||
function mapStatus(status?: string | null): SubscriptionStatus {
|
||||
if (!status) return "Cancelled";
|
||||
const mapped = STATUS_MAP[status.trim().toLowerCase()];
|
||||
return mapped ?? "Cancelled";
|
||||
return normalizeStatus(status ?? undefined, STATUS_MAP, "Cancelled");
|
||||
}
|
||||
|
||||
function mapCycle(cycle?: string | null): SubscriptionCycle {
|
||||
if (!cycle) return "One-time";
|
||||
const normalized = cycle.trim().toLowerCase().replace(/[_\s-]+/g, " ");
|
||||
return CYCLE_MAP[normalized] ?? "One-time";
|
||||
}
|
||||
|
||||
function parseAmount(amount: string | number | undefined): number {
|
||||
if (typeof amount === "number") {
|
||||
return amount;
|
||||
}
|
||||
if (!amount) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const cleaned = String(amount).replace(/[^\d.-]/g, "");
|
||||
const parsed = Number.parseFloat(cleaned);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
function formatDate(input?: string | null): string | undefined {
|
||||
if (!input) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const date = new Date(input);
|
||||
if (Number.isNaN(date.getTime())) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return date.toISOString();
|
||||
return normalizeCycle(cycle ?? undefined, CYCLE_MAP, "One-time");
|
||||
}
|
||||
|
||||
function extractCustomFields(raw: unknown): Record<string, string> | undefined {
|
||||
@ -166,3 +148,67 @@ export function transformWhmcsSubscriptions(
|
||||
): Subscription[] {
|
||||
return rawProducts.map(raw => transformWhmcsSubscription(raw, options));
|
||||
}
|
||||
|
||||
export function transformWhmcsSubscriptionListResponse(
|
||||
response: unknown,
|
||||
options: TransformSubscriptionListResponseOptions = {}
|
||||
): SubscriptionList {
|
||||
const parsed = whmcsProductListResponseSchema.parse(response);
|
||||
const { status, onItemError, ...subscriptionOptions } = options;
|
||||
|
||||
if (parsed.result === "error") {
|
||||
const message = parsed.message || "WHMCS GetClientsProducts returned error";
|
||||
throw new Error(message);
|
||||
}
|
||||
|
||||
const productContainer = parsed.products?.product;
|
||||
const products = Array.isArray(productContainer)
|
||||
? productContainer
|
||||
: productContainer
|
||||
? [productContainer]
|
||||
: [];
|
||||
|
||||
const subscriptions: Subscription[] = [];
|
||||
for (const product of products) {
|
||||
try {
|
||||
const subscription = transformWhmcsSubscription(product, subscriptionOptions);
|
||||
subscriptions.push(subscription);
|
||||
} catch (error) {
|
||||
onItemError?.(error, product);
|
||||
}
|
||||
}
|
||||
|
||||
const totalResultsRaw = parsed.totalresults;
|
||||
const totalResults =
|
||||
typeof totalResultsRaw === "number"
|
||||
? totalResultsRaw
|
||||
: typeof totalResultsRaw === "string"
|
||||
? Number.parseInt(totalResultsRaw, 10)
|
||||
: subscriptions.length;
|
||||
|
||||
if (status) {
|
||||
const normalizedStatus = subscriptionStatusSchema.parse(status);
|
||||
const filtered = subscriptions.filter(sub => sub.status === normalizedStatus);
|
||||
return subscriptionListSchema.parse({
|
||||
subscriptions: filtered,
|
||||
totalCount: filtered.length,
|
||||
});
|
||||
}
|
||||
|
||||
return subscriptionListSchema.parse({
|
||||
subscriptions,
|
||||
totalCount: Number.isFinite(totalResults) ? totalResults : subscriptions.length,
|
||||
});
|
||||
}
|
||||
|
||||
export function filterSubscriptionsByStatus(
|
||||
list: SubscriptionList,
|
||||
status: SubscriptionStatus | string
|
||||
): SubscriptionList {
|
||||
const normalizedStatus = subscriptionStatusSchema.parse(status);
|
||||
const filtered = list.subscriptions.filter(sub => sub.status === normalizedStatus);
|
||||
return subscriptionListSchema.parse({
|
||||
subscriptions: filtered,
|
||||
totalCount: filtered.length,
|
||||
});
|
||||
}
|
||||
|
||||
@ -13,6 +13,7 @@
|
||||
"common/**/*",
|
||||
"customer/**/*",
|
||||
"dashboard/**/*",
|
||||
"providers/**/*",
|
||||
"mappings/**/*",
|
||||
"orders/**/*",
|
||||
"payments/**/*",
|
||||
|
||||
@ -1,28 +0,0 @@
|
||||
export function parseAmount(amount: string | number | undefined): number {
|
||||
if (typeof amount === "number") return amount;
|
||||
if (!amount) return 0;
|
||||
|
||||
const cleaned = String(amount).replace(/[^\d.-]/g, "");
|
||||
const parsed = Number.parseFloat(cleaned);
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
}
|
||||
|
||||
export function formatDate(input: string | undefined): string | undefined {
|
||||
if (!input) return undefined;
|
||||
const date = new Date(input);
|
||||
return Number.isNaN(date.getTime()) ? undefined : date.toISOString();
|
||||
}
|
||||
|
||||
export function toErrorMessage(error: unknown): string {
|
||||
if (error instanceof Error) {
|
||||
return error.message;
|
||||
}
|
||||
if (typeof error === "string") {
|
||||
return error;
|
||||
}
|
||||
try {
|
||||
return JSON.stringify(error);
|
||||
} catch {
|
||||
return String(error);
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user