diff --git a/backend/services/ARCHITECTURE.md b/backend/services/ARCHITECTURE.md new file mode 100644 index 00000000..4bf8b368 --- /dev/null +++ b/backend/services/ARCHITECTURE.md @@ -0,0 +1,140 @@ +# Backend Services Architecture + +## Module Boundaries + +``` +backend/services/ +├── container.ts # IoC Container — sole coupling point +├── index.ts # Public API barrel +├── shared/ # Cross-cutting infrastructure +│ ├── errors.ts # DomainError base class +│ ├── logging.ts # Structured logger +│ ├── encryption.ts # PII encryption & blind indexes +│ ├── apiResponse.ts # Standard API response envelope +│ ├── apiClient.ts # HTTP client +│ ├── auditService.ts # Audit trail +│ ├── monitoring.ts # Health checks & metrics +│ ├── rateLimitingService.ts # Rate limiting +│ ├── gdpr.ts # Data subject requests +│ ├── keyManager.ts # Key rotation +│ └── piiAudit.ts # PII access audit +├── subscription/ # Subscription domain +│ ├── interfaces.ts # ISubscriptionEventStore, IElasticsearchService +│ ├── errors.ts # SubscriptionError + SubscriptionErrorCode +│ ├── subscriptionEventStore.ts +│ ├── ElasticsearchService.ts +│ └── __tests__/ +├── billing/ # Billing domain +│ ├── interfaces.ts # IMeteringService, IPricingService, ITaxService, etc. +│ ├── errors.ts # BillingError + BillingErrorCode +│ ├── meteringService.ts +│ ├── pricingService.ts +│ ├── taxService.ts +│ ├── dunningService.ts +│ ├── accountingExportService.ts +│ └── __tests__/ +├── notification/ # Notification domain +│ ├── interfaces.ts # INotificationPreferenceService, IAlertingService, etc. +│ ├── errors.ts # NotificationError + NotificationErrorCode +│ ├── preferenceService.ts +│ ├── alerting.ts +│ ├── webhook.ts +│ ├── websocket.ts +│ └── __tests__/ +└── analytics/ # Analytics domain + ├── interfaces.ts # IPredictionService, IRecommendationService, etc. + ├── errors.ts # AnalyticsError + AnalyticsErrorCode + ├── campaignService.ts + ├── complianceReport.ts + ├── dataPipeline.ts + ├── dataWarehouse.ts + ├── predictionService.ts + ├── recommendationService.ts + ├── retentionService.ts + ├── oracleMonitorService.ts + └── __tests__/ +``` + +## Domain Modules + +### subscription +**Responsibility:** Subscription lifecycle, event sourcing, full-text search. +**Interfaces:** `ISubscriptionEventStore`, `IElasticsearchService` +**Depends on:** `shared` (errors, types, logging) +**DOES NOT depend on:** `billing`, `notification`, `analytics` + +### billing +**Responsibility:** Usage metering, pricing, tax calculation, dunning, accounting exports. +**Interfaces:** `IMeteringService`, `IPricingService`, `ITaxService`, `IDunningService`, `IAccountingExportService` +**Depends on:** `shared` (errors, types, logging) +**DOES NOT depend on:** `subscription`, `notification`, `analytics` + +### notification +**Responsibility:** Push notifications, webhooks, alerts, WebSocket real-time, user preferences. +**Interfaces:** `INotificationPreferenceService`, `IAlertingService`, `IWebhookDeliveryService`, `IWebsocketService` +**Depends on:** `shared` (errors, types, logging) +**DOES NOT depend on:** `subscription`, `billing`, `analytics` + +### analytics +**Responsibility:** Campaigns, churn prediction, recommendations, compliance reports, oracle data. +**Interfaces:** `IPredictionService`, `IRecommendationService`, `IComplianceReportService`, `ICampaignService` +**Depends on:** `shared` (errors, types, logging) +**DOES NOT depend on:** `subscription`, `billing`, `notification` + +## Dependency Injection + +All cross-module communication flows through the `Container` in `container.ts`. Modules NEVER import concrete classes from sibling domains — they only depend on interfaces registered with I-prefix tokens. + +```typescript +// ✅ CORRECT — resolve via container +const billing = container.resolve('IBillingService'); + +// ❌ WRONG — direct cross-module import +import { SubscriptionEventStore } from '../subscription/subscriptionEventStore'; +``` + +### Container API + +| Method | Description | +|--------|-------------| +| `register(token, instance)` | Register an eager singleton | +| `bind(token, factory, lifetime?)` | Lazy binding (singleton by default) | +| `bindTransient(token, factory)` | New instance on every resolve | +| `resolve(token)` | Resolve a dependency (throws if missing) | +| `tryResolve(token)` | Resolve or return null | +| `has(token)` | Check if token is registered | +| `registerModule(reg)` | Bulk-register module bindings | +| `disposeAll()` | Call dispose() on all Disposable singletons | +| `clear()` | Reset all bindings (test isolation) | +| `listTokens()` | List all registered tokens | + +## Error Handling + +Each module has its own error class extending `DomainError` and a set of typed error codes: + +- `SubscriptionError` / `SubscriptionErrorCode` — `SUB_NOT_FOUND`, `SUB_EVENT_STORE_FULL`, etc. +- `BillingError` / `BillingErrorCode` — `BILL_PAYMENT_FAILED`, `BILL_TAX_CALCULATION_FAILED`, etc. +- `NotificationError` / `NotificationErrorCode` — `NOTIF_DELIVERY_FAILED`, `NOTIF_WEBHOOK_HEALTH_FAILED`, etc. +- `AnalyticsError` / `AnalyticsErrorCode` — `ANALYTICS_PREDICTION_FAILED`, `ANALYTICS_INSUFFICIENT_DATA`, etc. + +Every error includes a factory method for common cases (e.g. `SubscriptionError.notFound(id)`). + +## Anti-Patterns (Avoid) + +1. **Cross-module imports of concrete classes** — Always use interfaces + container +2. **Circular dependencies between modules** — Container detects and throws +3. **Shared mutable state** — Each module owns its own state +4. **Direct filesystem access between modules** — Use the shared infrastructure layer +5. **Module A importing from module B's internal utils** — Abstract via shared/ or interfaces + +## Testing + +Each module has `__tests__/module.test.ts` validating: +- Error codes are unique and correctly typed +- DI container bindings resolve correctly +- Container edge cases (circular deps, missing tokens, transient vs singleton) + +Run module-level tests: +```bash +npm test -- --testPathPattern="backend/services/.*/module.test.ts" +``` diff --git a/backend/services/analytics/__tests__/module.test.ts b/backend/services/analytics/__tests__/module.test.ts new file mode 100644 index 00000000..438bdcf6 --- /dev/null +++ b/backend/services/analytics/__tests__/module.test.ts @@ -0,0 +1,65 @@ +/** + * Module-level tests for analytics domain. + * Validates error codes and DI container integration. + */ +import { Container } from '../../container'; +import { AnalyticsError, AnalyticsErrorCode } from '../errors'; + +describe('Analytics Module', () => { + // ── Error handling ────────────────────────────────────────────────────────── + + describe('AnalyticsError', () => { + it('creates predictionFailed error', () => { + const err = AnalyticsError.predictionFailed('0xabc', 'insufficient_history'); + expect(err.code).toBe(AnalyticsErrorCode.PREDICTION_FAILED); + expect(err.details).toEqual({ subscriberAddress: '0xabc', reason: 'insufficient_history' }); + }); + + it('creates insufficientData error', () => { + const err = AnalyticsError.insufficientData('churn_rate'); + expect(err.code).toBe(AnalyticsErrorCode.INSUFFICIENT_DATA); + expect(err.details).toEqual({ metric: 'churn_rate' }); + }); + + it('creates oracleFetchFailed error', () => { + const err = AnalyticsError.oracleFetchFailed('ETH', 'timeout'); + expect(err.code).toBe(AnalyticsErrorCode.ORACLE_FETCH_FAILED); + expect(err.details).toEqual({ token: 'ETH', reason: 'timeout' }); + }); + + it('all error codes are unique within the module', () => { + const codes = Object.values(AnalyticsErrorCode); + expect(new Set(codes).size).toBe(codes.length); + }); + }); + + // ── DI Container bindings ─────────────────────────────────────────────────── + + describe('DI Container', () => { + let container: Container; + + beforeEach(() => { + container = new Container(); + }); + + it('resolves IPredictionService', () => { + container.bind('IPredictionService', () => ({ predictChurn: jest.fn() })); + expect(container.resolve('IPredictionService')).toBeDefined(); + }); + + it('resolves IRecommendationService', () => { + container.bind('IRecommendationService', () => ({ getRecommendations: jest.fn() })); + expect(container.resolve('IRecommendationService')).toBeDefined(); + }); + + it('resolves IComplianceReportService', () => { + container.bind('IComplianceReportService', () => ({ generateComplianceReport: jest.fn() })); + expect(container.resolve('IComplianceReportService')).toBeDefined(); + }); + + it('resolves ICampaignService', () => { + container.bind('ICampaignService', () => ({ createCampaign: jest.fn() })); + expect(container.resolve('ICampaignService')).toBeDefined(); + }); + }); +}); diff --git a/backend/services/analytics/errors.ts b/backend/services/analytics/errors.ts index f6a777b5..178901fe 100644 --- a/backend/services/analytics/errors.ts +++ b/backend/services/analytics/errors.ts @@ -1,8 +1,49 @@ import { DomainError } from '../shared/errors'; import { ErrorCode } from '../shared/apiResponse'; +/** + * Analytics module error codes. + * All codes follow pattern: ANALYTICS_[CATEGORY]_[SPECIFIC] + */ +export const AnalyticsErrorCode = { + PREDICTION_FAILED: 'ANALYTICS_PREDICTION_FAILED' as ErrorCode, + RECOMMENDATION_FAILED: 'ANALYTICS_RECOMMENDATION_FAILED' as ErrorCode, + REPORT_GENERATION_FAILED: 'ANALYTICS_REPORT_GENERATION_FAILED' as ErrorCode, + DATA_PIPELINE_FAILED: 'ANALYTICS_DATA_PIPELINE_FAILED' as ErrorCode, + DATA_WAREHOUSE_FAILED: 'ANALYTICS_DATA_WAREHOUSE_FAILED' as ErrorCode, + CAMPAIGN_CREATION_FAILED: 'ANALYTICS_CAMPAIGN_CREATION_FAILED' as ErrorCode, + COUPON_VALIDATION_FAILED: 'ANALYTICS_COUPON_VALIDATION_FAILED' as ErrorCode, + ORACLE_FETCH_FAILED: 'ANALYTICS_ORACLE_FETCH_FAILED' as ErrorCode, + INSUFFICIENT_DATA: 'ANALYTICS_INSUFFICIENT_DATA' as ErrorCode, + RETENTION_ANALYSIS_FAILED: 'ANALYTICS_RETENTION_ANALYSIS_FAILED' as ErrorCode, +} as const; + export class AnalyticsError extends DomainError { constructor(code: ErrorCode, message: string, details?: Record) { super(code, message, details); } + + static predictionFailed(subscriberAddress: string, reason: string): AnalyticsError { + return new AnalyticsError( + AnalyticsErrorCode.PREDICTION_FAILED, + `Churn prediction failed for ${subscriberAddress}: ${reason}`, + { subscriberAddress, reason } + ); + } + + static insufficientData(metric: string): AnalyticsError { + return new AnalyticsError( + AnalyticsErrorCode.INSUFFICIENT_DATA, + `Insufficient data to compute ${metric}`, + { metric } + ); + } + + static oracleFetchFailed(token: string, reason: string): AnalyticsError { + return new AnalyticsError( + AnalyticsErrorCode.ORACLE_FETCH_FAILED, + `Oracle price fetch failed for ${token}: ${reason}`, + { token, reason } + ); + } } diff --git a/backend/services/analytics/index.ts b/backend/services/analytics/index.ts index 8e319aea..ccb27758 100644 --- a/backend/services/analytics/index.ts +++ b/backend/services/analytics/index.ts @@ -11,4 +11,4 @@ export type { Recommendation, RecommendationContext } from './recommendationServ export { RetentionService } from './retentionService'; export { OracleMonitorService, oracleMonitorService } from './oracleMonitorService'; export type { IPredictionService, IRecommendationService, IComplianceReportService, ICampaignService } from './interfaces'; -export { AnalyticsError } from './errors'; +export { AnalyticsError, AnalyticsErrorCode } from './errors'; diff --git a/backend/services/billing/__tests__/module.test.ts b/backend/services/billing/__tests__/module.test.ts new file mode 100644 index 00000000..62b914f5 --- /dev/null +++ b/backend/services/billing/__tests__/module.test.ts @@ -0,0 +1,122 @@ +/** + * Module-level tests for billing domain. + * Validates error codes, DI container bindings, and module boundaries. + */ +import { container, Container } from '../../container'; +import { BillingError, BillingErrorCode } from '../errors'; + +describe('Billing Module', () => { + // ── Error handling ────────────────────────────────────────────────────────── + + describe('BillingError', () => { + it('creates paymentFailed error with correct code', () => { + const err = BillingError.paymentFailed('sub_123', 'insufficient_funds'); + expect(err.code).toBe(BillingErrorCode.PAYMENT_FAILED); + expect(err.message).toContain('sub_123'); + expect(err.message).toContain('insufficient_funds'); + expect(err.details).toEqual({ subscriptionId: 'sub_123', reason: 'insufficient_funds' }); + }); + + it('creates taxCalculationFailed error', () => { + const err = BillingError.taxCalculationFailed('merchant_1', 'invalid_nexus'); + expect(err.code).toBe(BillingErrorCode.TAX_CALCULATION_FAILED); + }); + + it('creates dunningFailed error', () => { + const err = BillingError.dunningFailed('sub_456', 'final_notice'); + expect(err.code).toBe(BillingErrorCode.DUNNING_FAILED); + expect(err.details).toEqual({ subscriptionId: 'sub_456', stage: 'final_notice' }); + }); + + it('all error codes are unique within the module', () => { + const codes = Object.values(BillingErrorCode); + expect(new Set(codes).size).toBe(codes.length); + }); + }); + + // ── DI Container bindings ─────────────────────────────────────────────────── + + describe('DI Container', () => { + let testContainer: Container; + + beforeEach(() => { + testContainer = new Container(); + }); + + it('resolves IMeteringService', () => { + testContainer.bind('IMeteringService', () => ({ recordUsage: jest.fn() })); + const svc = testContainer.resolve('IMeteringService'); + expect(svc).toBeDefined(); + expect(typeof svc.recordUsage).toBe('function'); + }); + + it('resolves IPricingService', () => { + testContainer.bind('IPricingService', () => ({ calculateOptimalPrice: jest.fn() })); + const svc = testContainer.resolve('IPricingService'); + expect(svc).toBeDefined(); + }); + + it('resolves ITaxService', () => { + testContainer.bind('ITaxService', () => ({ calculateTax: jest.fn() })); + const svc = testContainer.resolve('ITaxService'); + expect(svc).toBeDefined(); + }); + + it('resolves IDunningService', () => { + testContainer.bind('IDunningService', () => ({ startDunning: jest.fn() })); + const svc = testContainer.resolve('IDunningService'); + expect(svc).toBeDefined(); + }); + + it('throws for unregistered token', () => { + expect(() => testContainer.resolve('IUnregisteredService')).toThrow( + 'Service not registered' + ); + }); + + it('binds singletons by default (same instance)', () => { + testContainer.bind('ITestService', () => ({})); + const a = testContainer.resolve('ITestService'); + const b = testContainer.resolve('ITestService'); + expect(a).toBe(b); + }); + + it('binds transients (new instance each resolve)', () => { + testContainer.bindTransient('ITestService', () => ({})); + const a = testContainer.resolve('ITestService'); + const b = testContainer.resolve('ITestService'); + expect(a).not.toBe(b); + }); + + it('detects circular dependencies', () => { + testContainer.bind('IA', (c) => c.resolve('IB')); + testContainer.bind('IB', (c) => c.resolve('IA')); + expect(() => testContainer.resolve('IA')).toThrow('Circular dependency'); + }); + + it('has() returns true for registered tokens', () => { + testContainer.bind('ITestService', () => ({})); + expect(testContainer.has('ITestService')).toBe(true); + expect(testContainer.has('IUnknown')).toBe(false); + }); + + it('clear() removes all bindings', () => { + testContainer.bind('ITestService', () => ({})); + expect(testContainer.has('ITestService')).toBe(true); + testContainer.clear(); + expect(testContainer.has('ITestService')).toBe(false); + }); + }); + + // ── Module boundary ───────────────────────────────────────────────────────── + + describe('Module boundary', () => { + it('billing module does not import from subscription directly', () => { + // Interfaces enforce the boundary — concrete classes are never imported across modules. + // The container is the sole coupling point. + expect(container.has('IMeteringService')).toBe(true); + expect(container.has('ISubscriptionEventStore')).toBe(true); + // They live in different domain modules but are wired via the container. + }); + }); +}); diff --git a/backend/services/billing/errors.ts b/backend/services/billing/errors.ts index 8d29f9c3..efbeea47 100644 --- a/backend/services/billing/errors.ts +++ b/backend/services/billing/errors.ts @@ -1,8 +1,49 @@ import { DomainError } from '../shared/errors'; import { ErrorCode } from '../shared/apiResponse'; +/** + * Billing module error codes. + * All codes follow pattern: BILL_[CATEGORY]_[SPECIFIC] + */ +export const BillingErrorCode = { + INVOICE_NOT_FOUND: 'BILL_INVOICE_NOT_FOUND' as ErrorCode, + PAYMENT_FAILED: 'BILL_PAYMENT_FAILED' as ErrorCode, + TAX_CALCULATION_FAILED: 'BILL_TAX_CALCULATION_FAILED' as ErrorCode, + METERING_FAILED: 'BILL_METERING_FAILED' as ErrorCode, + PRICING_FAILED: 'BILL_PRICING_FAILED' as ErrorCode, + DUNNING_FAILED: 'BILL_DUNNING_FAILED' as ErrorCode, + RECONCILIATION_FAILED: 'BILL_RECONCILIATION_FAILED' as ErrorCode, + EXPORT_FAILED: 'BILL_EXPORT_FAILED' as ErrorCode, + OVERAGE_EXCEEDED: 'BILL_OVERAGE_EXCEEDED' as ErrorCode, + INVALID_PLAN: 'BILL_INVALID_PLAN' as ErrorCode, +} as const; + export class BillingError extends DomainError { constructor(code: ErrorCode, message: string, details?: Record) { super(code, message, details); } + + static paymentFailed(subscriptionId: string, reason: string): BillingError { + return new BillingError( + BillingErrorCode.PAYMENT_FAILED, + `Payment failed for subscription ${subscriptionId}: ${reason}`, + { subscriptionId, reason } + ); + } + + static taxCalculationFailed(merchantId: string, reason: string): BillingError { + return new BillingError( + BillingErrorCode.TAX_CALCULATION_FAILED, + `Tax calculation failed for merchant ${merchantId}: ${reason}`, + { merchantId, reason } + ); + } + + static dunningFailed(subscriptionId: string, stage: string): BillingError { + return new BillingError( + BillingErrorCode.DUNNING_FAILED, + `Dunning failed for subscription ${subscriptionId} at stage ${stage}`, + { subscriptionId, stage } + ); + } } diff --git a/backend/services/billing/index.ts b/backend/services/billing/index.ts index b457304f..4cf7ccc2 100644 --- a/backend/services/billing/index.ts +++ b/backend/services/billing/index.ts @@ -30,4 +30,4 @@ export type { ReconciliationResult, } from './accountingExportService'; export type { IMeteringService, IPricingService, ITaxService, IDunningService, IAccountingExportService } from './interfaces'; -export { BillingError } from './errors'; +export { BillingError, BillingErrorCode } from './errors'; diff --git a/backend/services/container.ts b/backend/services/container.ts index 236880a9..8f358efa 100644 --- a/backend/services/container.ts +++ b/backend/services/container.ts @@ -1,3 +1,17 @@ +/** + * IoC Container — lightweight dependency injection with support for: + * - Singleton & transient lifetimes + * - Lazy factory bindings (resolved on first access) + * - Module-level bulk registration + * - Lifecycle hooks (init / dispose) + * - Circular dependency detection + * - Test isolation via clear() + * + * Module boundaries are enforced through token-based registration. + * Modules depend on interfaces (I-prefixed tokens), never on concrete + * classes from sibling modules. Cross-module coupling flows exclusively + * through the container. + */ import { subscriptionEventStore } from './subscription/subscriptionEventStore'; import { elasticsearchService } from './subscription/ElasticsearchService'; import { MeteringService } from './billing/meteringService'; @@ -16,64 +30,189 @@ import { RecommendationService } from './analytics/recommendationService'; import { RetentionService } from './analytics/retentionService'; import { oracleMonitorService } from './analytics/oracleMonitorService'; +// ─── Types ──────────────────────────────────────────────────────────────────── + +export type Lifetime = 'singleton' | 'transient'; + +export interface Binding { + token: string | symbol; + factory: (c: Container) => T; + lifetime: Lifetime; + instance?: T; +} + +export interface ModuleRegistration { + module: string; + bindings: Array<{ token: string | symbol; factory: (c: Container) => unknown; lifetime?: Lifetime }>; +} + +export interface Disposable { + dispose(): void | Promise; +} + +// ─── Container ──────────────────────────────────────────────────────────────── + export class Container { - private services = new Map(); - private factories = new Map any>(); + private bindings = new Map(); + private resolving = new Set(); // Circular dependency detection + private disposed = false; + + /** Extract a token key from various forms. */ + private keyOf(token: string | symbol | { new (...args: any[]): unknown }): string | symbol { + if (typeof token === 'string' || typeof token === 'symbol') return token; + return token.name; + } + + // ── Registration ────────────────────────────────────────────────────────── - /** Register a singleton instance of a service. */ + /** Register an already-created singleton instance (eager). */ register(token: string | symbol | { new (...args: any[]): T }, instance: T): void { - const key = typeof token === 'function' ? token.name : token; - this.services.set(key, instance); + this.ensureNotDisposed(); + const key = this.keyOf(token); + this.bindings.set(key, { + token: key, + factory: () => instance, + lifetime: 'singleton', + instance, + }); + } + + /** Register a lazy factory. The factory runs once for singletons, every time for transients. */ + bind( + token: string | symbol | { new (...args: any[]): T }, + factory: (c: Container) => T, + lifetime: Lifetime = 'singleton' + ): void { + this.ensureNotDisposed(); + const key = this.keyOf(token); + this.bindings.set(key, { token: key, factory, lifetime }); + } + + /** Convenience: bind a class constructor with transient lifetime (new instance each resolve). */ + bindTransient(token: string | symbol | { new (...args: any[]): T }, factory: (c: Container) => T): void { + this.bind(token, factory, 'transient'); } - /** Register a factory function for lazy resolution. */ - registerFactory(token: string | symbol | { new (...args: any[]): T }, factory: (c: Container) => T): void { - const key = typeof token === 'function' ? token.name : token; - this.factories.set(key, factory); + /** Bulk-register a module's bindings. */ + registerModule(registration: ModuleRegistration): void { + for (const { token, factory, lifetime } of registration.bindings) { + this.bind(token, factory, lifetime ?? 'singleton'); + } } - /** Resolve a dependency by its token or constructor. */ + // ── Resolution ──────────────────────────────────────────────────────────── + + /** Resolve a dependency. Throws if not registered. */ resolve(token: string | symbol | { new (...args: any[]): T }): T { - const key = typeof token === 'function' ? token.name : token; - if (this.services.has(key)) { - return this.services.get(key); + this.ensureNotDisposed(); + const key = this.keyOf(token); + const binding = this.bindings.get(key); + + if (!binding) { + throw new Error( + `[Container] Service not registered: ${String(key)}. ` + + `Registered tokens: [${[...this.bindings.keys()].map(String).join(', ')}]` + ); + } + + // Circular dependency guard + if (this.resolving.has(key)) { + throw new Error( + `[Container] Circular dependency detected: ${String(key)} is already being resolved. ` + + `Resolution chain: [${[...this.resolving].map(String).join(' -> ')}]` + ); + } + + // Singleton already cached + if (binding.lifetime === 'singleton' && binding.instance !== undefined) { + return binding.instance as T; } - if (this.factories.has(key)) { - const factory = this.factories.get(key); - const instance = factory(this); - this.services.set(key, instance); // Cache as singleton + + this.resolving.add(key); + try { + const instance = binding.factory(this); + if (binding.lifetime === 'singleton') { + binding.instance = instance; + } return instance; + } finally { + this.resolving.delete(key); + } + } + + /** Try to resolve, returning null instead of throwing. */ + tryResolve(token: string | symbol | { new (...args: any[]): T }): T | null { + try { + return this.resolve(token); + } catch { + return null; + } + } + + /** Check if a token is registered. */ + has(token: string | symbol | { new (...args: any[]): unknown }): boolean { + return this.bindings.has(this.keyOf(token)); + } + + // ── Lifecycle ───────────────────────────────────────────────────────────── + + /** Call dispose() on all registered Disposable singletons. */ + async disposeAll(): Promise { + this.disposed = true; + for (const [, binding] of this.bindings) { + if (binding.instance && typeof (binding.instance as Disposable).dispose === 'function') { + await (binding.instance as Disposable).dispose(); + } } - throw new Error(`Service not registered for token: ${String(key)}`); + this.bindings.clear(); } - /** Reset all registered services and factories (useful for test isolation). */ + /** Reset all bindings (for test isolation). */ clear(): void { - this.services.clear(); - this.factories.clear(); + this.disposed = false; + this.bindings.clear(); + this.resolving.clear(); + } + + /** List all registered token keys (useful for debugging). */ + listTokens(): string[] { + return [...this.bindings.keys()].map(String); + } + + // ── Private ─────────────────────────────────────────────────────────────── + + private ensureNotDisposed(): void { + if (this.disposed) { + throw new Error('[Container] Cannot register or resolve after disposeAll()'); + } } } export const container = new Container(); // ── Default Bindings ────────────────────────────────────────────────────────── + +// ── Subscription ────────────────────────────────────────────────────────────── container.register('ISubscriptionEventStore', subscriptionEventStore); container.register('IElasticsearchService', elasticsearchService); -container.register('IMeteringService', new MeteringService()); -container.register('IPricingService', new PricingService()); -container.register('ITaxService', new TaxService()); +// ── Billing ─────────────────────────────────────────────────────────────────── +container.bind('IMeteringService', () => new MeteringService()); +container.bind('IPricingService', () => new PricingService()); +container.bind('ITaxService', () => new TaxService()); container.register('IDunningService', dunningService); -container.register('INotificationPreferenceService', new NotificationPreferenceService()); -container.register('IAlertingService', new AlertingService()); +// ── Notification ────────────────────────────────────────────────────────────── +container.bind('INotificationPreferenceService', () => new NotificationPreferenceService()); +container.bind('IAlertingService', () => new AlertingService()); container.register('IWebhookDeliveryService', webhookDeliveryService); container.register('IWebsocketService', webSocketServer); -container.register('ICampaignService', new CampaignService()); -container.register('IDataPipelineService', new DataPipelineService()); -container.register('IDataWarehouseService', new DataWarehouseService()); -container.register('IPredictionService', new PredictionService()); -container.register('IRecommendationService', new RecommendationService()); -container.register('IRetentionService', new RetentionService()); +// ── Analytics ───────────────────────────────────────────────────────────────── +container.bind('ICampaignService', () => new CampaignService()); +container.bind('IDataPipelineService', () => new DataPipelineService()); +container.bind('IDataWarehouseService', () => new DataWarehouseService()); +container.bind('IPredictionService', () => new PredictionService()); +container.bind('IRecommendationService', () => new RecommendationService()); +container.bind('IRetentionService', () => new RetentionService()); container.register('IOracleMonitorService', oracleMonitorService); diff --git a/backend/services/notification/__tests__/module.test.ts b/backend/services/notification/__tests__/module.test.ts new file mode 100644 index 00000000..c5b5a159 --- /dev/null +++ b/backend/services/notification/__tests__/module.test.ts @@ -0,0 +1,64 @@ +/** + * Module-level tests for notification domain. + * Validates error codes and DI container integration. + */ +import { Container } from '../../container'; +import { NotificationError, NotificationErrorCode } from '../errors'; + +describe('Notification Module', () => { + // ── Error handling ────────────────────────────────────────────────────────── + + describe('NotificationError', () => { + it('creates deliveryFailed error', () => { + const err = NotificationError.deliveryFailed('user_abc', 'channel_unavailable'); + expect(err.code).toBe(NotificationErrorCode.DELIVERY_FAILED); + expect(err.details).toEqual({ recipientId: 'user_abc', reason: 'channel_unavailable' }); + }); + + it('creates webhookDeliveryFailed error', () => { + const err = NotificationError.webhookDeliveryFailed('wh_001', 502); + expect(err.code).toBe(NotificationErrorCode.WEBHOOK_DELIVERY_FAILED); + expect(err.details).toEqual({ webhookId: 'wh_001', statusCode: '502' }); + }); + + it('creates alertDispatchFailed error', () => { + const err = NotificationError.alertDispatchFailed('pagerduty', 'High Error Rate'); + expect(err.code).toBe(NotificationErrorCode.ALERT_DISPATCH_FAILED); + }); + + it('all error codes are unique within the module', () => { + const codes = Object.values(NotificationErrorCode); + expect(new Set(codes).size).toBe(codes.length); + }); + }); + + // ── DI Container bindings ─────────────────────────────────────────────────── + + describe('DI Container', () => { + let container: Container; + + beforeEach(() => { + container = new Container(); + }); + + it('resolves INotificationPreferenceService', () => { + container.bind('INotificationPreferenceService', () => ({ getPreferences: jest.fn() })); + expect(container.resolve('INotificationPreferenceService')).toBeDefined(); + }); + + it('resolves IAlertingService', () => { + container.bind('IAlertingService', () => ({ dispatch: jest.fn() })); + expect(container.resolve('IAlertingService')).toBeDefined(); + }); + + it('resolves IWebhookDeliveryService', () => { + container.bind('IWebhookDeliveryService', () => ({ deliverEvent: jest.fn() })); + expect(container.resolve('IWebhookDeliveryService')).toBeDefined(); + }); + + it('resolves IWebsocketService', () => { + container.bind('IWebsocketService', () => ({ connect: jest.fn(), disconnect: jest.fn() })); + expect(container.resolve('IWebsocketService')).toBeDefined(); + }); + }); +}); diff --git a/backend/services/notification/errors.ts b/backend/services/notification/errors.ts index 9261e45c..21b033ad 100644 --- a/backend/services/notification/errors.ts +++ b/backend/services/notification/errors.ts @@ -1,8 +1,49 @@ import { DomainError } from '../shared/errors'; import { ErrorCode } from '../shared/apiResponse'; +/** + * Notification module error codes. + * All codes follow pattern: NOTIF_[CATEGORY]_[SPECIFIC] + */ +export const NotificationErrorCode = { + DELIVERY_FAILED: 'NOTIF_DELIVERY_FAILED' as ErrorCode, + PREFERENCE_NOT_FOUND: 'NOTIF_PREFERENCE_NOT_FOUND' as ErrorCode, + WEBHOOK_REGISTRATION_FAILED: 'NOTIF_WEBHOOK_REGISTRATION_FAILED' as ErrorCode, + WEBHOOK_DELIVERY_FAILED: 'NOTIF_WEBHOOK_DELIVERY_FAILED' as ErrorCode, + WEBHOOK_HEALTH_FAILED: 'NOTIF_WEBHOOK_HEALTH_FAILED' as ErrorCode, + ALERT_DISPATCH_FAILED: 'NOTIF_ALERT_DISPATCH_FAILED' as ErrorCode, + WEBSOCKET_CONNECTION_FAILED: 'NOTIF_WEBSOCKET_CONNECTION_FAILED' as ErrorCode, + BROADCAST_FAILED: 'NOTIF_BROADCAST_FAILED' as ErrorCode, + INVALID_CHANNEL_CONFIG: 'NOTIF_INVALID_CHANNEL_CONFIG' as ErrorCode, + RATE_LIMITED: 'NOTIF_RATE_LIMITED' as ErrorCode, +} as const; + export class NotificationError extends DomainError { constructor(code: ErrorCode, message: string, details?: Record) { super(code, message, details); } + + static deliveryFailed(recipientId: string, reason: string): NotificationError { + return new NotificationError( + NotificationErrorCode.DELIVERY_FAILED, + `Notification delivery failed for ${recipientId}: ${reason}`, + { recipientId, reason } + ); + } + + static webhookDeliveryFailed(webhookId: string, statusCode: number): NotificationError { + return new NotificationError( + NotificationErrorCode.WEBHOOK_DELIVERY_FAILED, + `Webhook delivery failed for ${webhookId} (HTTP ${statusCode})`, + { webhookId, statusCode: String(statusCode) } + ); + } + + static alertDispatchFailed(channel: string, alertTitle: string): NotificationError { + return new NotificationError( + NotificationErrorCode.ALERT_DISPATCH_FAILED, + `Failed to dispatch alert "${alertTitle}" via ${channel}`, + { channel, alertTitle } + ); + } } diff --git a/backend/services/notification/index.ts b/backend/services/notification/index.ts index a37a8cff..f75cc967 100644 --- a/backend/services/notification/index.ts +++ b/backend/services/notification/index.ts @@ -7,4 +7,4 @@ export type { RegisterWebhookInput, WebhookDeliveryResult } from './webhook'; export { WebSocketServer, webSocketServer } from './websocket'; export type { SubscriptionEventType, SubscriptionEvent, EventFilter, ClientInfo } from './websocket'; export type { INotificationPreferenceService, IAlertingService, IWebhookDeliveryService, IWebsocketService } from './interfaces'; -export { NotificationError } from './errors'; +export { NotificationError, NotificationErrorCode } from './errors'; diff --git a/backend/services/subscription/__tests__/module.test.ts b/backend/services/subscription/__tests__/module.test.ts new file mode 100644 index 00000000..23e5e6c9 --- /dev/null +++ b/backend/services/subscription/__tests__/module.test.ts @@ -0,0 +1,55 @@ +/** + * Module-level tests for subscription domain. + * Validates error codes and DI container integration. + */ +import { Container } from '../../container'; +import { SubscriptionError, SubscriptionErrorCode } from '../errors'; + +describe('Subscription Module', () => { + // ── Error handling ────────────────────────────────────────────────────────── + + describe('SubscriptionError', () => { + it('creates notFound error with id', () => { + const err = SubscriptionError.notFound('sub_xyz'); + expect(err.code).toBe(SubscriptionErrorCode.NOT_FOUND); + expect(err.message).toContain('sub_xyz'); + expect(err.details).toEqual({ id: 'sub_xyz' }); + }); + + it('creates alreadyExists error', () => { + const err = SubscriptionError.alreadyExists('sub_dup'); + expect(err.code).toBe(SubscriptionErrorCode.ALREADY_EXISTS); + }); + + it('creates invalidState error with expected and actual', () => { + const err = SubscriptionError.invalidState('sub_1', 'active', 'cancelled'); + expect(err.code).toBe(SubscriptionErrorCode.INVALID_STATE); + expect(err.details).toEqual({ id: 'sub_1', expected: 'active', actual: 'cancelled' }); + }); + + it('all error codes are unique within the module', () => { + const codes = Object.values(SubscriptionErrorCode); + expect(new Set(codes).size).toBe(codes.length); + }); + }); + + // ── DI Container bindings ─────────────────────────────────────────────────── + + describe('DI Container', () => { + let container: Container; + + beforeEach(() => { + container = new Container(); + }); + + it('resolves ISubscriptionEventStore', () => { + container.bind('ISubscriptionEventStore', () => ({ append: jest.fn(), query: jest.fn() })); + expect(container.resolve('ISubscriptionEventStore')).toBeDefined(); + }); + + it('resolves IElasticsearchService', () => { + container.bind('IElasticsearchService', () => ({ search: jest.fn(), indexDocument: jest.fn() })); + expect(container.resolve('IElasticsearchService')).toBeDefined(); + }); + }); +}); diff --git a/backend/services/subscription/errors.ts b/backend/services/subscription/errors.ts index 6e78c79b..768e0487 100644 --- a/backend/services/subscription/errors.ts +++ b/backend/services/subscription/errors.ts @@ -1,8 +1,41 @@ import { DomainError } from '../shared/errors'; import { ErrorCode } from '../shared/apiResponse'; +/** + * Subscription module error codes. + * All codes follow pattern: SUB_[CATEGORY]_[SPECIFIC] + */ +export const SubscriptionErrorCode = { + NOT_FOUND: 'SUB_NOT_FOUND' as ErrorCode, + ALREADY_EXISTS: 'SUB_ALREADY_EXISTS' as ErrorCode, + INVALID_STATE: 'SUB_INVALID_STATE' as ErrorCode, + EVENT_STORE_FULL: 'SUB_EVENT_STORE_FULL' as ErrorCode, + REPLAY_FAILED: 'SUB_REPLAY_FAILED' as ErrorCode, + SEARCH_INDEX_ERROR: 'SUB_SEARCH_INDEX_ERROR' as ErrorCode, + INVALID_SEARCH_QUERY: 'SUB_INVALID_SEARCH_QUERY' as ErrorCode, + RECONSTRUCTION_FAILED: 'SUB_RECONSTRUCTION_FAILED' as ErrorCode, + ARCHIVE_FAILED: 'SUB_ARCHIVE_FAILED' as ErrorCode, + VALIDATION_ERROR: 'SUB_VALIDATION_ERROR' as ErrorCode, +} as const; + export class SubscriptionError extends DomainError { constructor(code: ErrorCode, message: string, details?: Record) { super(code, message, details); } + + static notFound(id: string): SubscriptionError { + return new SubscriptionError(SubscriptionErrorCode.NOT_FOUND, `Subscription not found: ${id}`, { id }); + } + + static alreadyExists(id: string): SubscriptionError { + return new SubscriptionError(SubscriptionErrorCode.ALREADY_EXISTS, `Subscription already exists: ${id}`, { id }); + } + + static invalidState(id: string, expected: string, actual: string): SubscriptionError { + return new SubscriptionError( + SubscriptionErrorCode.INVALID_STATE, + `Invalid state for subscription ${id}: expected ${expected}, got ${actual}`, + { id, expected, actual } + ); + } } diff --git a/backend/services/subscription/index.ts b/backend/services/subscription/index.ts index ec59a60c..9938dd74 100644 --- a/backend/services/subscription/index.ts +++ b/backend/services/subscription/index.ts @@ -3,4 +3,4 @@ export type { SubscriptionEvent, SubscriptionEventPage, SubscriptionEventQuery, export { ElasticsearchService, elasticsearchService } from './ElasticsearchService'; export type { SearchQuery, SearchHit, FacetResult, SearchResult, SearchAnalyticsEvent } from './ElasticsearchService'; export type { ISubscriptionEventStore, IElasticsearchService } from './interfaces'; -export { SubscriptionError } from './errors'; +export { SubscriptionError, SubscriptionErrorCode } from './errors'; diff --git a/developer-portal/pages/OnboardingPage.tsx b/developer-portal/pages/OnboardingPage.tsx index e9033ba9..36e14640 100644 --- a/developer-portal/pages/OnboardingPage.tsx +++ b/developer-portal/pages/OnboardingPage.tsx @@ -75,6 +75,7 @@ export const OnboardingPage: React.FC = ({ onComplete }) => }); const completedCount = steps.filter((s) => s.completed).length; + const _requiredCount = steps.filter((s) => s.isRequired).length; const allRequiredCompleted = steps.filter((s) => s.isRequired).every((s) => s.completed); const handleCompleteStep = (stepId: string) => { diff --git a/developer-portal/services/developerPortalService.ts b/developer-portal/services/developerPortalService.ts index 53d359ef..7a02076b 100644 --- a/developer-portal/services/developerPortalService.ts +++ b/developer-portal/services/developerPortalService.ts @@ -106,7 +106,7 @@ export class DeveloperPortalService { this.developers.set(developerId, developer); - await this.createApiKey(developerId, 'Default API Key', 'test', [ + const _apiKey = await this.createApiKey(developerId, 'Default API Key', 'test', [ 'subscriptions:read', 'subscriptions:write', 'payments:read', diff --git a/sandbox/services/usageTrackingService.ts b/sandbox/services/usageTrackingService.ts index dc9cbaa0..ee16da62 100644 --- a/sandbox/services/usageTrackingService.ts +++ b/sandbox/services/usageTrackingService.ts @@ -1,4 +1,4 @@ -import { UsageMetrics } from '../types/sandbox'; +import { UsageMetrics, HourlyUsage, DailyUsage } from '../types/sandbox'; export class UsageTrackingService { private usageData: Map = new Map(); diff --git a/src/store/fraudStore.ts b/src/store/fraudStore.ts index 7a0d4dc8..542e7f69 100644 --- a/src/store/fraudStore.ts +++ b/src/store/fraudStore.ts @@ -483,6 +483,7 @@ const scoreSubscription = ( evidence, }; }; + const computeAnalytics = ( subscriptions: FraudSubscriptionRecord[], reviewQueue: FraudCase[]