12 KiB
12 KiB
Type Cleanup & Architecture Guide
🎯 Goal
Establish a single source of truth for every cross-layer contract so backend integrations, internal services, and the Portal all share identical type definitions and runtime validation.
No business code should re-declare data shapes, and every external payload must be validated exactly once at the boundary.
📐 Ideal State
Layer Architecture
┌─────────────────────────────────────────────────┐
│ @customer-portal/contracts │
│ Pure TypeScript interfaces (no runtime deps) │
│ → billing, subscriptions, payments, SIM, etc. │
└────────────────┬────────────────────────────────┘
│
↓
┌─────────────────────────────────────────────────┐
│ @customer-portal/schemas │
│ Zod validators for each contract │
│ → Billing, SIM, Payments, Integrations │
└────────────────┬────────────────────────────────┘
│
↓
┌─────────────────────────────────────────────────┐
│ Integration Packages │
│ → WHMCS mappers (billing, orders, payments) │
│ → Freebit mappers (SIM operations) │
│ Transform raw API data → contracts │
└────────────────┬────────────────────────────────┘
│
↓
┌─────────────────────────────────────────────────┐
│ Application Layers │
│ → BFF (NestJS): Orchestrates integrations │
│ → Portal (Next.js): UI and client logic │
│ Only import from contracts/schemas │
└─────────────────────────────────────────────────┘
🗂️ Package Structure
1. Contracts (packages/contracts/src/)
Purpose: Pure TypeScript types - the single source of truth.
packages/contracts/src/
├── billing/
│ ├── invoice.ts # Invoice, InvoiceItem, InvoiceList
│ └── index.ts
├── subscriptions/
│ ├── subscription.ts # Subscription, SubscriptionList
│ └── index.ts
├── payments/
│ ├── payment.ts # PaymentMethod, PaymentGateway
│ └── index.ts
├── sim/
│ ├── sim-details.ts # SimDetails, SimUsage, SimTopUpHistory
│ └── index.ts
├── orders/
│ ├── order.ts # Order, OrderItem, FulfillmentOrderItem
│ └── index.ts
└── freebit/
├── requests.ts # Freebit request payloads
└── index.ts
Usage:
import type { Invoice, InvoiceItem } from "@customer-portal/contracts/billing";
import type { SimDetails } from "@customer-portal/contracts/sim";
2. Schemas (packages/schemas/src/)
Purpose: Runtime validation using Zod schemas.
packages/schemas/src/
├── billing/
│ ├── invoice.schema.ts # invoiceSchema, invoiceListSchema
│ └── index.ts
├── subscriptions/
│ ├── subscription.schema.ts
│ └── index.ts
├── payments/
│ ├── payment.schema.ts
│ └── index.ts
├── sim/
│ ├── sim.schema.ts
│ └── index.ts
└── integrations/
├── whmcs/
│ ├── invoice.schema.ts # Raw WHMCS invoice schemas
│ ├── payment.schema.ts
│ ├── product.schema.ts
│ ├── order.schema.ts # NEW: WHMCS AddOrder schemas
│ └── index.ts
└── freebit/
├── account.schema.ts # Raw Freebit response schemas
├── traffic.schema.ts
├── quota.schema.ts
└── requests/
├── topup.schema.ts
├── plan-change.schema.ts
├── esim-activation.schema.ts # NEW
├── features.schema.ts # NEW
└── index.ts
Usage:
import { invoiceSchema } from "@customer-portal/schemas/billing";
import { whmcsAddOrderParamsSchema } from "@customer-portal/schemas/integrations/whmcs/order.schema";
import { freebitEsimActivationParamsSchema } from "@customer-portal/schemas/integrations/freebit/requests/esim-activation.schema";
// Validate at the boundary
const validated = invoiceSchema.parse(externalData);
3. Integration Packages
WHMCS Integration (packages/integrations/whmcs/)
packages/integrations/whmcs/src/
├── mappers/
│ ├── invoice.mapper.ts # transformWhmcsInvoice()
│ ├── subscription.mapper.ts # transformWhmcsSubscription()
│ ├── payment.mapper.ts # transformWhmcsPaymentMethod()
│ ├── order.mapper.ts # mapFulfillmentOrderItems(), buildWhmcsAddOrderPayload()
│ └── index.ts
├── utils/
│ └── index.ts
└── index.ts
Key Functions:
transformWhmcsInvoice(raw)→InvoicetransformWhmcsSubscription(raw)→SubscriptionmapFulfillmentOrderItems(items)→{ whmcsItems, summary }buildWhmcsAddOrderPayload(params)→ WHMCS API payloadcreateOrderNotes(sfOrderId, notes)→ formatted order notes
Usage in BFF:
import { transformWhmcsInvoice, buildWhmcsAddOrderPayload } from "@customer-portal/integrations-whmcs/mappers";
// Transform WHMCS raw data
const invoice = transformWhmcsInvoice(whmcsResponse);
// Build order payload
const payload = buildWhmcsAddOrderPayload({
clientId: 123,
items: mappedItems,
paymentMethod: "stripe",
});
Freebit Integration (packages/integrations/freebit/)
packages/integrations/freebit/src/
├── mappers/
│ ├── sim.mapper.ts # transformFreebitAccountDetails(), transformFreebitTrafficInfo()
│ └── index.ts
├── utils/
│ ├── normalize.ts # normalizeAccount()
│ └── index.ts
└── index.ts
Key Functions:
transformFreebitAccountDetails(raw)→SimDetailstransformFreebitTrafficInfo(raw)→SimUsagetransformFreebitQuotaHistory(raw)→SimTopUpHistory[]normalizeAccount(account)→ normalized MSISDN
Usage in BFF:
import { transformFreebitAccountDetails } from "@customer-portal/integrations-freebit/mappers";
import { normalizeAccount } from "@customer-portal/integrations-freebit/utils";
const simDetails = transformFreebitAccountDetails(freebitResponse);
const account = normalizeAccount(msisdn);
🚫 Anti-Patterns to Avoid
❌ Don't re-declare types in application code
// BAD - duplicating contract in BFF
export interface Invoice {
id: string;
amount: number;
// ...
}
// GOOD - import from contracts
import type { Invoice } from "@customer-portal/contracts/billing";
❌ Don't skip schema validation at boundaries
// BAD - trusting external data
const invoice = whmcsResponse as Invoice;
// GOOD - validate with schema
import { transformWhmcsInvoice } from "@customer-portal/integrations-whmcs/mappers";
const invoice = transformWhmcsInvoice(whmcsResponse); // Validates internally
❌ Don't use legacy domain imports
// BAD - old path
import type { Invoice } from "@customer-portal/domain";
// GOOD - new path
import type { Invoice } from "@customer-portal/contracts/billing";
🔧 Migration Checklist
For WHMCS Order Workflows
- Create
whmcs/order.schema.tswithWhmcsOrderItem,WhmcsAddOrderParams, etc. - Move
buildWhmcsAddOrderPayload()towhmcs/mappers/order.mapper.ts - Update
WhmcsOrderServiceto use shared mapper - Update BFF order orchestrator to consume mapper outputs
- Add unit tests for mapper functions
For Freebit Requests
- Create
freebit/requests/esim-activation.schema.ts - Create
freebit/requests/features.schema.ts - Update
FreebitOperationsServiceto validate requests through schemas - Centralize options normalization in integration package
- Add regression tests for schema validation
For Portal Alignment
- Update SIM components to import from
@customer-portal/contracts/sim - Remove lingering
@customer-portal/domainimports - Update API client typings to use shared contracts
For Governance
- Document layer rules in
ARCHITECTURE.md - Create this
TYPE-CLEANUP-GUIDE.md - Add ESLint rules preventing deep imports from legacy paths
📚 Import Examples
BFF (NestJS)
// Controllers
import type { Invoice, InvoiceList } from "@customer-portal/contracts/billing";
import { invoiceListSchema } from "@customer-portal/schemas/billing";
// Services
import { transformWhmcsInvoice } from "@customer-portal/integrations-whmcs/mappers";
// Operations
const invoices = rawInvoices.map(transformWhmcsInvoice);
const validated = invoiceListSchema.parse({ invoices, total });
Portal (Next.js)
// Components
import type { SimDetails } from "@customer-portal/contracts/sim";
import type { Invoice } from "@customer-portal/contracts/billing";
// API Client
export async function fetchInvoices(): Promise<InvoiceList> {
const response = await api.get<InvoiceList>("/api/invoices");
return response.data;
}
🎓 Best Practices
- Always validate at boundaries: Use schemas when receiving data from external APIs
- Import from subpaths: Use
@customer-portal/contracts/billingnot@customer-portal/contracts - Use mappers in integrations: Keep transformation logic in integration packages
- Don't export Zod schemas from contracts: Contracts are type-only, schemas are runtime
- Keep integrations thin in BFF: Let integration packages handle complex mapping
🔍 Finding the Right Import
| What you need | Where to import from |
|---|---|
| TypeScript type definition | @customer-portal/contracts/{domain} |
| Runtime validation | @customer-portal/schemas/{domain} |
| External API validation | @customer-portal/schemas/integrations/{provider} |
| Transform external data | @customer-portal/integrations-{provider}/mappers |
| Normalize/format helpers | @customer-portal/integrations-{provider}/utils |
💡 Quick Reference
// ✅ CORRECT Usage Pattern
// 1. Import types from contracts
import type { Invoice } from "@customer-portal/contracts/billing";
// 2. Import schema for validation
import { invoiceSchema } from "@customer-portal/schemas/billing";
// 3. Import mapper from integration
import { transformWhmcsInvoice } from "@customer-portal/integrations-whmcs/mappers";
// 4. Use in service
const invoice: Invoice = transformWhmcsInvoice(rawData); // Auto-validates
const validated = invoiceSchema.parse(invoice); // Optional explicit validation
Last Updated: 2025-10-03
For questions or clarifications, refer to docs/ARCHITECTURE.md or the package README files.