diff --git a/insureportal/.env.example b/insureportal/.env.example index 43feb81958..529b23c7d7 100644 --- a/insureportal/.env.example +++ b/insureportal/.env.example @@ -1,317 +1,80 @@ -# ───────────────────────────────────────────────────────────────────────────── -# NGApp Platform — Environment Variables -# Copy this file to .env and fill in production values -# ───────────────────────────────────────────────────────────────────────────── - -# ══════════════════════════════════════════════════════════════════════════════ -# CORE APPLICATION -# ══════════════════════════════════════════════════════════════════════════════ -NODE_ENV=production -PORT=3000 -APP_URL=https://your-domain.com -APP_UPSTREAM_URL=http://localhost:3000 -APP_UPSTREAM_HOST=localhost:3000 -API_VERSION=v1 -SERVICE_NAME=ngapp -SERVICE_VERSION=1.0.0 -LOG_LEVEL=info -ALLOWED_ORIGINS=https://your-domain.com -INTERNAL_API_KEY= -CRON_SECRET= -DEV_AUTH_BYPASS=false - -# ══════════════════════════════════════════════════════════════════════════════ -# DATABASE (PostgreSQL) -# ══════════════════════════════════════════════════════════════════════════════ -DATABASE_URL=postgres://user:password@db-host:5432/ngapp?sslmode=require -POSTGRES_URL=postgres://user:password@db-host:5432/ngapp?sslmode=require - -# ══════════════════════════════════════════════════════════════════════════════ -# REDIS -# ══════════════════════════════════════════════════════════════════════════════ -REDIS_URL=redis://redis-host:6379 -REDIS_HOST=redis-host -REDIS_PORT=6379 - -# ══════════════════════════════════════════════════════════════════════════════ -# AUTHENTICATION (Keycloak OIDC) -# ══════════════════════════════════════════════════════════════════════════════ -KEYCLOAK_URL=https://auth.your-domain.com -KEYCLOAK_REALM=ngapp -KEYCLOAK_CLIENT_ID=ngapp-client -KEYCLOAK_CLIENT_SECRET= -OAUTH_SERVER_URL=https://auth.your-domain.com -OWNER_OPEN_ID= -JWT_SECRET= - -# ══════════════════════════════════════════════════════════════════════════════ -# AUTHORIZATION (Permify) -# ══════════════════════════════════════════════════════════════════════════════ -PERMIFY_URL=http://permify:3476 -PERMIFY_HOST=permify -PERMIFY_PORT=3476 -PERMIFY_API_KEY= -PERMIFY_TENANT_ID= -PERMIFY_ENABLED=true - -# ══════════════════════════════════════════════════════════════════════════════ -# SECRETS MANAGEMENT (HashiCorp Vault) -# ══════════════════════════════════════════════════════════════════════════════ -VAULT_ADDR=https://vault.your-domain.com -VAULT_ROLE_ID= -VAULT_SECRET_ID= -VAULT_SECRET_PATH=secret/data/ngapp - -# ══════════════════════════════════════════════════════════════════════════════ -# MESSAGE BROKER (Kafka) -# ══════════════════════════════════════════════════════════════════════════════ -KAFKA_BROKERS=kafka-1:9092,kafka-2:9092 -KAFKA_BROKER=kafka-1:9092 -KAFKA_BROKER_HOST=kafka-1 -KAFKA_BROKER_PORT=9092 -KAFKA_BROKER_URL=kafka-1:9092 -KAFKA_CLIENT_ID=ngapp-producer -KAFKA_GROUP_ID=ngapp-consumers -KAFKA_ENABLED=true -KAFKA_SSL=true -KAFKA_SASL_USERNAME= -KAFKA_SASL_PASSWORD= -KAFKA_ADMIN_URL=http://kafka-admin:8083 -SCHEMA_REGISTRY_URL=http://schema-registry:8081 - -# ══════════════════════════════════════════════════════════════════════════════ -# EVENT STREAMING (Fluvio) -# ══════════════════════════════════════════════════════════════════════════════ -FLUVIO_HTTP_URL=http://fluvio:9090 -FLUVIO_HOST=fluvio -FLUVIO_PORT=9003 -FLUVIO_ENDPOINT=http://fluvio:9003 -FLUVIO_API_KEY= -FLUVIO_STREAMING_URL=http://fluvio:8095 - -# ══════════════════════════════════════════════════════════════════════════════ -# WORKFLOW ORCHESTRATION (Temporal) -# ══════════════════════════════════════════════════════════════════════════════ -TEMPORAL_ADDRESS=temporal:7233 -TEMPORAL_NAMESPACE=ngapp -TEMPORAL_TASK_QUEUE=ngapp-tasks - -# ══════════════════════════════════════════════════════════════════════════════ -# SERVICE MESH (Dapr) -# ══════════════════════════════════════════════════════════════════════════════ -DAPR_HTTP_PORT=3500 - -# ══════════════════════════════════════════════════════════════════════════════ -# LEDGER (TigerBeetle) -# ══════════════════════════════════════════════════════════════════════════════ -TIGERBEETLE_HOST=tigerbeetle -TIGERBEETLE_PORT=3320 -TIGERBEETLE_CLUSTER_ID=0 -TIGERBEETLE_HEALTH_URL=http://tigerbeetle:9090 -TIGERBEETLE_INTEGRATED_URL=http://tigerbeetle-service:8082 -TB_SIDECAR_URL=http://tigerbeetle-sidecar:3320 -GO_LEDGER_URL=http://ledger-service:8301 - -# ══════════════════════════════════════════════════════════════════════════════ -# PAYMENTS (Mojaloop) -# ══════════════════════════════════════════════════════════════════════════════ -MOJALOOP_HUB_URL=http://mojaloop-hub:4001 -MOJALOOP_DFSP_ID=ngapp-dfsp -MOJALOOP_SIDECAR_URL=http://mojaloop-sidecar:4002 - -# ══════════════════════════════════════════════════════════════════════════════ -# SEARCH & ANALYTICS (OpenSearch) -# ══════════════════════════════════════════════════════════════════════════════ -OPENSEARCH_URL=https://opensearch:9200 -OPENSEARCH_ENDPOINT=https://opensearch:9200 -OPENSEARCH_USER=admin -OPENSEARCH_PASSWORD= -OPENSEARCH_ANALYTICS_URL=http://analytics-service:8093 - -# ══════════════════════════════════════════════════════════════════════════════ -# DATA LAKEHOUSE -# ══════════════════════════════════════════════════════════════════════════════ -LAKEHOUSE_URL=http://lakehouse:8181 -LAKEHOUSE_SERVICE_URL=http://lakehouse-service:8181 -LAKEHOUSE_SIDECAR_URL=http://lakehouse-sidecar:8181 -LAKEHOUSE_SERVICE_TOKEN= -LAKEHOUSE_CATALOG=iceberg -LAKEHOUSE_SCHEMA=ngapp_analytics -TRINO_URL=http://trino:8080 - -# ══════════════════════════════════════════════════════════════════════════════ -# API GATEWAY (APISIX) -# ══════════════════════════════════════════════════════════════════════════════ -APISIX_ADMIN_URL=http://apisix:9180 -APISIX_ADMIN_KEY= - -# ══════════════════════════════════════════════════════════════════════════════ -# SECURITY (OpenAppSec / WAF) -# ══════════════════════════════════════════════════════════════════════════════ -DDOS_SHIELD_URL=http://openappsec:7777 -SECURITY_SERVICE_TIMEOUT=5000 -SECURITY_FAIL_OPEN=false -MTLS_ENABLED=true -MTLS_CERT_DIR=/etc/certs - -# ══════════════════════════════════════════════════════════════════════════════ -# OBJECT STORAGE (MinIO/S3) -# ══════════════════════════════════════════════════════════════════════════════ -MINIO_ENDPOINT=https://s3.your-domain.com -MINIO_ACCESS_KEY= -MINIO_SECRET_KEY= -MINIO_BUCKET=ngapp-uploads -MINIO_REGION=us-east-1 -S3_REGION=us-east-1 -S3_PRESIGN_EXPIRY_SECONDS=3600 - -# ══════════════════════════════════════════════════════════════════════════════ -# OBSERVABILITY (OpenTelemetry) -# ══════════════════════════════════════════════════════════════════════════════ -OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 -OTEL_SERVICE_NAME=ngapp -OTEL_SERVICE_VERSION=1.0.0 - -# ══════════════════════════════════════════════════════════════════════════════ -# EMAIL -# ══════════════════════════════════════════════════════════════════════════════ -SMTP_HOST=smtp.your-domain.com +# InsurePortal Environment Configuration +# Copy to .env and fill in values for your environment + +# ─── Core ──────────────────────────────────────────────────────────────────── +NODE_ENV=development +PORT=5000 + +# ─── Database ──────────────────────────────────────────────────────────────── +DATABASE_URL=postgresql://postgres:postgres@localhost:5432/insureportal + +# ─── Redis ─────────────────────────────────────────────────────────────────── +REDIS_URL=redis://localhost:6379 + +# ─── Kafka ─────────────────────────────────────────────────────────────────── +KAFKA_BROKERS=localhost:9092 +KAFKA_CLIENT_ID=insureportal-api +KAFKA_GROUP_ID=insureportal-consumer + +# ─── Keycloak Auth ─────────────────────────────────────────────────────────── +KEYCLOAK_URL=http://localhost:8080 +KEYCLOAK_REALM=insureportal +KEYCLOAK_CLIENT_ID=insureportal-web +KEYCLOAK_CLIENT_SECRET=your-client-secret + +# ─── Temporal Workflow Engine ──────────────────────────────────────────────── +TEMPORAL_ADDRESS=localhost:7233 +TEMPORAL_NAMESPACE=insureportal +TEMPORAL_TASK_QUEUE=insureportal-main + +# ─── OpenSearch ────────────────────────────────────────────────────────────── +OPENSEARCH_ENDPOINT=http://localhost:9200 +OPENSEARCH_USERNAME=admin +OPENSEARCH_PASSWORD=admin + +# ─── Service Discovery ─────────────────────────────────────────────────────── +SERVICE_DISCOVERY_HOST=localhost +KYB_ENGINE_URL=http://localhost:8130 +KYB_RISK_ENGINE_URL=http://localhost:8131 +KYB_ANALYTICS_URL=http://localhost:8132 +DEEPFACE_URL=http://localhost:8133 +KYC_ENFORCEMENT_URL=http://localhost:8211 +AML_CASE_MANAGER_URL=http://localhost:8212 +CBN_TIER_ENGINE_URL=http://localhost:8213 +SANCTIONS_RESCREENER_URL=http://localhost:8214 +KYC_WORKFLOW_URL=http://localhost:8215 +GOAML_SERVICE_URL=http://localhost:8210 + +# ─── APISIX Gateway ───────────────────────────────────────────────────────── +APISIX_ADMIN_URL=http://localhost:9180 +APISIX_ADMIN_KEY=your-admin-key + +# ─── TigerBeetle ──────────────────────────────────────────────────────────── +TIGERBEETLE_HEALTH_URL=http://localhost:9090 + +# ─── OpenTelemetry ─────────────────────────────────────────────────────────── +OTEL_ENABLED=true +OTEL_SERVICE_NAME=insureportal-api +OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318 +PROMETHEUS_METRICS_PORT=9464 + +# ─── Email (Nodemailer) ───────────────────────────────────────────────────── +SMTP_HOST=smtp.example.com SMTP_PORT=587 -SMTP_SECURE=true -SMTP_USER= -SMTP_PASS= -SMTP_FROM=noreply@your-domain.com -EMAIL_FROM=noreply@your-domain.com -SENDGRID_API_KEY= - -# ══════════════════════════════════════════════════════════════════════════════ -# SMS / NOTIFICATIONS -# ══════════════════════════════════════════════════════════════════════════════ -TERMII_API_KEY= -TERMII_SENDER_ID=NGApp -TWILIO_ACCOUNT_SID= -TWILIO_AUTH_TOKEN= -TWILIO_FROM_NUMBER= -AT_API_KEY= -AT_USERNAME= -AT_SENDER_ID=NGApp - -# ══════════════════════════════════════════════════════════════════════════════ -# PUSH NOTIFICATIONS (Web Push / VAPID) -# ══════════════════════════════════════════════════════════════════════════════ -VAPID_PUBLIC_KEY= -VAPID_PRIVATE_KEY= -VAPID_SUBJECT=mailto:admin@your-domain.com - -# ══════════════════════════════════════════════════════════════════════════════ -# PAYMENTS (Stripe) -# ══════════════════════════════════════════════════════════════════════════════ -STRIPE_SECRET_KEY= -STRIPE_WEBHOOK_SECRET= - -# ══════════════════════════════════════════════════════════════════════════════ -# IoT / MQTT -# ══════════════════════════════════════════════════════════════════════════════ -MQTT_BROKER_URL=mqtt://mqtt-broker:1883 -MQTT_CLIENT_ID=ngapp-iot -MQTT_USERNAME= -MQTT_PASSWORD= - -# ══════════════════════════════════════════════════════════════════════════════ -# COMPLIANCE & KYC/KYB -# ══════════════════════════════════════════════════════════════════════════════ -BIOMETRIC_SERVICE_URL=http://biometric-service:8046 -LIVENESS_SERVICE_URL=http://liveness-service:8104 -FACE_MATCHING_SERVICE_URL=http://face-matching:8105 -DEEPFAKE_SERVICE_URL=http://deepfake-detection:8106 -DEEPFACE_URL=http://deepface:8133 -KYC_WORKFLOW_URL=http://kyc-workflow:8080 -KYC_ENFORCEMENT_URL=http://kyc-enforcement:8080 -KYC_EVENT_CONSUMER_URL=http://kyc-events:8080 -KYB_ENGINE_URL=http://kyb-engine:8080 -KYB_ANALYTICS_URL=http://kyb-analytics:8080 -KYB_RISK_ENGINE_URL=http://kyb-risk:8080 -COMPLIANCE_API_URL=http://compliance:8080 -COMPLIANCE_API_KEY= -GOAML_SERVICE_URL=http://goaml:8080 -SANCTIONS_ETL_URL=http://sanctions-etl:8080 -SANCTIONS_RESCREENER_URL=http://sanctions-rescreener:8080 -OFAC_SDN_URL=https://www.treasury.gov/ofac/downloads/sdn.xml -AML_CASE_MANAGER_URL=http://aml-case-manager:8080 - -# ══════════════════════════════════════════════════════════════════════════════ -# GO MICROSERVICES -# ══════════════════════════════════════════════════════════════════════════════ -WORKFLOW_ORCHESTRATOR_URL=http://workflow-orchestrator:8081 -MDM_COMPLIANCE_URL=http://mdm-compliance:8083 -MDM_COMPLIANCE_ENGINE_URL=http://mdm-engine:8083 -MDM_GEOFENCE_SERVICE_URL=http://mdm-geofence:8083 -PBAC_ENGINE_URL=http://pbac-engine:8084 -CONNECTIVITY_RESILIENCE_URL=http://connectivity:8085 -BILLING_AGGREGATOR_URL=http://billing-aggregator:8086 -RBAC_SERVICE_URL=http://rbac-service:8087 -USSD_GATEWAY_URL=http://ussd-gateway:8088 -USSD_TX_PROCESSOR_URL=http://ussd-tx:8089 -HIERARCHY_ENGINE_URL=http://hierarchy-engine:8090 -SETTLEMENT_GATEWAY_URL=http://settlement-gateway:8091 -AT_USSD_HANDLER_URL=http://at-ussd:8092 -REVENUE_RECONCILER_URL=http://revenue-reconciler:8094 -CIRCUIT_BREAKER_URL=http://circuit-breaker:8080 - -# ══════════════════════════════════════════════════════════════════════════════ -# PLATFORM SERVICES -# ══════════════════════════════════════════════════════════════════════════════ -PLATFORM_BASE_URL=https://your-domain.com -PLATFORM_API_KEY= -PLATFORM_SERVICE_TOKEN= -PLATFORM_ANALYTICS_URL=http://analytics:8080 -PLATFORM_NOTIFICATION_URL=http://notifications:8080 -PLATFORM_FRAUD_URL=http://fraud:8103 -PLATFORM_SETTLEMENT_URL=http://settlement:8080 -PLATFORM_DISPUTE_URL=http://disputes:8080 -PLATFORM_FLOAT_URL=http://float:8107 -PLATFORM_KYC_URL=http://kyc:8080 -PLATFORM_GEOFENCING_URL=http://geofencing:8105 -PLATFORM_LOYALTY_URL=http://loyalty:8106 -PLATFORM_VIDEO_KYC_URL=http://video-kyc:8080 - -# ══════════════════════════════════════════════════════════════════════════════ -# AI / ML SERVICES -# ══════════════════════════════════════════════════════════════════════════════ -PYTHON_ML_URL=http://ml-service:8080 -ML_MODEL_REGISTRY_URL=http://model-registry:8080 -FRAUD_ML_URL=http://fraud-ml:8080 -INTELLIGENCE_SERVICE_URL=http://intelligence:8080 -ANALYTICS_SERVICE_URL=http://analytics:8080 - -# ══════════════════════════════════════════════════════════════════════════════ -# OTHER SERVICES -# ══════════════════════════════════════════════════════════════════════════════ -MARKETPLACE_URL=http://marketplace:8080 -CART_SERVICE_URL=http://cart-service:8080 -CATALOG_SERVICE_URL=http://catalog-service:8080 -BACKUP_MANAGER_URL=http://backup-manager:8080 -DATA_ARCHIVAL_URL=http://data-archival:8080 -OFFLINE_QUEUE_URL=http://offline-queue:8201 -SUPPLY_CHAIN_URL=http://supply-chain:8080 -WEBHOOK_DELIVERY_URL=http://webhook-delivery:8080 -RESILIENCE_AGENT_URL=http://resilience-agent:8080 -POS_PRINTER_URL=http://pos-printer:8080 -RUST_BRIDGE_URL=http://rust-bridge:8080 -CBN_REPORTING_SERVICE_URL=http://cbn-reporting:8080 -CBN_TIER_ENGINE_URL=http://cbn-tier:8080 -TX_SIGNING_SECRET= - -# ══════════════════════════════════════════════════════════════════════════════ -# FRONTEND (Vite — VITE_ prefix required for client access) -# ══════════════════════════════════════════════════════════════════════════════ -VITE_APP_ID=ngapp -VITE_ANALYTICS_ENDPOINT=https://analytics.your-domain.com -# VITE_ANALYTICS_WEBSITE_ID= (set in index.html) - -# ══════════════════════════════════════════════════════════════════════════════ -# FEATURE FLAGS -# ══════════════════════════════════════════════════════════════════════════════ -DEMO_MODE=false -BUILT_IN_FORGE_API_URL= -BUILT_IN_FORGE_API_KEY= +SMTP_USER=notifications@insureportal.ng +SMTP_PASS=your-smtp-password +EMAIL_FROM=InsurePortal + +# ─── AWS S3 (Document Storage) ────────────────────────────────────────────── +AWS_REGION=eu-west-1 +AWS_ACCESS_KEY_ID=your-access-key +AWS_SECRET_ACCESS_KEY=your-secret-key +S3_BUCKET=insureportal-documents + +# ─── Stripe (Premium Payments) ────────────────────────────────────────────── +STRIPE_SECRET_KEY=sk_test_your-key +STRIPE_WEBHOOK_SECRET=whsec_your-secret + +# ─── Session ───────────────────────────────────────────────────────────────── +SESSION_SECRET=change-me-in-production +JWT_SECRET=change-me-in-production diff --git a/insureportal/CONTRIBUTING.md b/insureportal/CONTRIBUTING.md new file mode 100644 index 0000000000..6c61370925 --- /dev/null +++ b/insureportal/CONTRIBUTING.md @@ -0,0 +1,100 @@ +# Contributing to InsurePortal + +## Getting Started + +### Prerequisites +- Node.js 20+ +- Go 1.21+ +- Python 3.11+ +- Docker & Docker Compose +- PostgreSQL 15+ + +### Setup +```bash +cd insureportal +npm install +cp .env.example .env # Edit with your local credentials +npm run db:push # Create database tables +npm run seed # Populate with demo data +npm run dev # Start development server +``` + +### Middleware (Docker Compose) +```bash +docker-compose -f infrastructure/docker-compose.staging.yml up -d +``` +This starts: PostgreSQL, Redis, Kafka, Keycloak, Temporal, OpenSearch. + +## Architecture + +``` +insureportal/ +├── client/src/ # React frontend (430 pages) +│ ├── pages/ # Route-based page components +│ ├── components/ # Shared UI components +│ ├── hooks/ # Custom React hooks +│ └── store/ # Zustand state management +├── server/ # tRPC backend (449 routers) +│ ├── _core/ # Server bootstrap, tRPC setup +│ ├── routers/ # Domain-specific route handlers +│ ├── middleware/ # Auth, rate limiting +│ └── db.ts # Database procedures +├── services/ # Polyglot microservices +│ ├── */main.go # Go services +│ ├── */main.py # Python services +│ └── */src/ # TypeScript/Rust services +├── shared/ # Shared types, schema, utilities +├── drizzle/ # Database schema & migrations +├── infrastructure/ # Helm, K8s, Docker, monitoring +└── docs/ # Architecture, deployment, API docs +``` + +## Development Workflow + +### Frontend +- Framework: React 19 + TypeScript + Tailwind CSS 4 +- Routing: wouter +- State: Zustand + React Query (TanStack) +- API: tRPC client +- UI: Radix UI + shadcn/ui patterns + +### Backend +- API: tRPC v11 on Express +- DB: PostgreSQL via Drizzle ORM +- Auth: Keycloak OpenID Connect +- Queue: Kafka (kafkajs) +- Cache: Redis (ioredis) +- Workflow: Temporal + +### Microservices +- **Go services**: `cd services/ && go build ./...` +- **Python services**: `cd services/ && python main.py` +- **TypeScript services**: Import from `services//src/index.ts` + +## Testing +```bash +npm test # Run all tests +npm run test:coverage # With coverage report +``` + +## Code Style +- TypeScript: Strict mode, no `any` (use `@ts-nocheck` only for legacy files) +- Go: Standard `go fmt` + `go vet` +- Python: PEP 8 +- Commits: Conventional commits (`feat:`, `fix:`, `chore:`) + +## NAICOM Compliance +All insurance features must comply with NAICOM regulations: +- Product codes: `NIC///` format +- Minimum capital requirements per license class +- Solvency margin calculations +- Quarterly return filing deadlines +- AML/CFT reporting thresholds (CBN ₦5M single / ₦10M cumulative) + +## Environment Variables +See `.env.example` for all required variables. Key groups: +- `DATABASE_URL` — PostgreSQL connection +- `REDIS_URL` — Redis cache +- `KAFKA_BROKERS` — Kafka cluster +- `KEYCLOAK_*` — Auth configuration +- `SERVICE_DISCOVERY_HOST` — Microservice hostname (K8s DNS or localhost) diff --git a/insureportal/drizzle.config.ts b/insureportal/drizzle.config.ts new file mode 100644 index 0000000000..f98a47f2c7 --- /dev/null +++ b/insureportal/drizzle.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from "drizzle-kit"; + +export default defineConfig({ + out: "./drizzle", + schema: "./shared/schema.ts", + dialect: "postgresql", + dbCredentials: { + url: process.env.DATABASE_URL!, + }, +}); diff --git a/insureportal/package.json b/insureportal/package.json index ee05fc8376..9c5c46dd31 100644 --- a/insureportal/package.json +++ b/insureportal/package.json @@ -9,9 +9,136 @@ "build": "vite build && esbuild server/_core/index.ts --platform=node --packages=external --bundle --format=esm --outdir=dist", "start": "NODE_ENV=production node dist/index.js", "test": "vitest run", + "test:coverage": "vitest run --coverage", "lint": "eslint client/src server --ext .ts,.tsx", "typecheck": "tsc --noEmit", "seed": "node server/seed-comprehensive.mjs", - "db:push": "drizzle-kit push" + "db:push": "drizzle-kit push", + "db:generate": "drizzle-kit generate", + "db:migrate": "drizzle-kit migrate" + }, + "dependencies": { + "@aws-sdk/client-s3": "^3.693.0", + "@aws-sdk/s3-request-presigner": "^3.693.0", + "@hookform/resolvers": "^5.2.2", + "@opentelemetry/auto-instrumentations-node": "^0.75.0", + "@opentelemetry/exporter-prometheus": "^0.217.0", + "@opentelemetry/exporter-trace-otlp-http": "^0.214.0", + "@opentelemetry/resources": "^2.6.1", + "@opentelemetry/sdk-node": "^0.217.0", + "@opentelemetry/semantic-conventions": "^1.40.0", + "@radix-ui/react-accordion": "^1.2.12", + "@radix-ui/react-alert-dialog": "^1.1.15", + "@radix-ui/react-avatar": "^1.1.10", + "@radix-ui/react-checkbox": "^1.3.3", + "@radix-ui/react-collapsible": "^1.1.12", + "@radix-ui/react-dialog": "^1.1.15", + "@radix-ui/react-dropdown-menu": "^2.1.16", + "@radix-ui/react-label": "^2.1.7", + "@radix-ui/react-popover": "^1.1.15", + "@radix-ui/react-progress": "^1.1.7", + "@radix-ui/react-radio-group": "^1.3.8", + "@radix-ui/react-scroll-area": "^1.2.10", + "@radix-ui/react-select": "^2.2.6", + "@radix-ui/react-separator": "^1.1.7", + "@radix-ui/react-slider": "^1.3.6", + "@radix-ui/react-slot": "^1.2.3", + "@radix-ui/react-switch": "^1.2.6", + "@radix-ui/react-tabs": "^1.1.13", + "@radix-ui/react-toggle": "^1.1.10", + "@radix-ui/react-toggle-group": "^1.1.11", + "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.90.2", + "@trpc/client": "^11.6.0", + "@trpc/react-query": "^11.6.0", + "@trpc/server": "^11.6.0", + "axios": "^1.12.0", + "bcryptjs": "^3.0.3", + "chart.js": "^4.5.1", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "cmdk": "^1.1.1", + "compression": "^1.8.1", + "cookie": "^1.0.2", + "date-fns": "^4.1.0", + "dotenv": "^17.2.2", + "drizzle-orm": "^0.44.7", + "embla-carousel-react": "^8.6.0", + "express": "^4.21.2", + "express-rate-limit": "^8.3.2", + "framer-motion": "^12.23.22", + "helmet": "^8.1.0", + "idb-keyval": "^6.2.2", + "input-otp": "^1.4.2", + "ioredis": "^5.10.1", + "jose": "6.1.0", + "kafkajs": "^2.2.4", + "leaflet": "^1.9.4", + "lucide-react": "^0.453.0", + "nanoid": "^5.1.5", + "next-themes": "^0.4.6", + "node-cron": "^4.2.1", + "nodemailer": "^8.0.4", + "openid-client": "^6.8.2", + "pdfkit": "^0.18.0", + "pg": "^8.20.0", + "pino": "^10.3.1", + "pino-http": "^11.0.0", + "prom-client": "^15.1.3", + "qrcode": "^1.5.4", + "qrcode.react": "^4.2.0", + "rate-limit-redis": "^4.3.1", + "react": "^19.2.1", + "react-chartjs-2": "^5.3.1", + "react-day-picker": "^9.11.1", + "react-dom": "^19.2.1", + "react-hook-form": "^7.64.0", + "react-leaflet": "^5.0.0", + "react-resizable-panels": "^3.0.6", + "recharts": "^2.15.2", + "socket.io": "^4.8.3", + "socket.io-client": "^4.8.3", + "sonner": "^2.0.7", + "stripe": "^22.0.2", + "superjson": "^1.13.3", + "tailwind-merge": "^3.3.1", + "tailwindcss-animate": "^1.0.7", + "vaul": "^1.1.2", + "web-push": "^3.6.7", + "wouter": "^3.3.5", + "yaml": "^2.9.0", + "zod": "^4.1.12", + "zustand": "^5.0.12" + }, + "devDependencies": { + "@tailwindcss/typography": "^0.5.15", + "@tailwindcss/vite": "^4.1.3", + "@types/bcryptjs": "^3.0.0", + "@types/compression": "^1.8.1", + "@types/express": "4.17.21", + "@types/leaflet": "^1.9.21", + "@types/node": "^24.7.0", + "@types/node-cron": "^3.0.11", + "@types/nodemailer": "^7.0.11", + "@types/pg": "^8.20.0", + "@types/qrcode": "^1.5.6", + "@types/react": "^19.2.1", + "@types/react-dom": "^19.2.1", + "@types/web-push": "^3.6.4", + "@vitejs/plugin-react": "^5.0.4", + "autoprefixer": "^10.4.20", + "drizzle-kit": "^0.31.4", + "esbuild": "^0.25.0", + "eslint": "^9.0.0", + "pino-pretty": "^13.1.3", + "postcss": "^8.4.47", + "prettier": "^3.6.2", + "tailwindcss": "^4.1.14", + "tsx": "^4.19.1", + "tw-animate-css": "^1.4.0", + "typescript": "5.9.3", + "vite": "^7.1.7", + "vitest": "^3.2.4", + "@vitest/coverage-v8": "^3.2.4" } } diff --git a/insureportal/server/instrumentation.ts b/insureportal/server/instrumentation.ts new file mode 100644 index 0000000000..da42017753 --- /dev/null +++ b/insureportal/server/instrumentation.ts @@ -0,0 +1,73 @@ +/** + * OpenTelemetry Instrumentation for InsurePortal + * Must be imported BEFORE any other modules to ensure proper instrumentation. + * + * Usage: import './instrumentation' at the top of server/_core/index.ts + * + * Exports metrics and traces to: + * - Prometheus (metrics): http://localhost:9464/metrics + * - OTLP (traces): configured via OTEL_EXPORTER_OTLP_ENDPOINT + */ + +import { NodeSDK } from "@opentelemetry/sdk-node"; +import { getNodeAutoInstrumentations } from "@opentelemetry/auto-instrumentations-node"; +import { PrometheusExporter } from "@opentelemetry/exporter-prometheus"; +import { OTLPTraceExporter } from "@opentelemetry/exporter-trace-otlp-http"; +import { Resource } from "@opentelemetry/resources"; +import { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } from "@opentelemetry/semantic-conventions"; + +const OTEL_ENABLED = process.env.OTEL_ENABLED !== "false"; +const SERVICE_NAME = process.env.OTEL_SERVICE_NAME || "insureportal-api"; +const SERVICE_VERSION = process.env.npm_package_version || "1.0.0"; +const OTLP_ENDPOINT = process.env.OTEL_EXPORTER_OTLP_ENDPOINT || "http://localhost:4318"; +const PROMETHEUS_PORT = parseInt(process.env.PROMETHEUS_METRICS_PORT || "9464", 10); + +let sdk: NodeSDK | undefined; + +if (OTEL_ENABLED) { + const resource = new Resource({ + [ATTR_SERVICE_NAME]: SERVICE_NAME, + [ATTR_SERVICE_VERSION]: SERVICE_VERSION, + "deployment.environment": process.env.NODE_ENV || "development", + "service.namespace": "insureportal", + }); + + const prometheusExporter = new PrometheusExporter({ + port: PROMETHEUS_PORT, + }); + + const traceExporter = new OTLPTraceExporter({ + url: `${OTLP_ENDPOINT}/v1/traces`, + }); + + sdk = new NodeSDK({ + resource, + metricReader: prometheusExporter, + traceExporter, + instrumentations: [ + getNodeAutoInstrumentations({ + "@opentelemetry/instrumentation-http": { + ignoreIncomingPaths: ["/health", "/metrics", "/favicon.ico"], + }, + "@opentelemetry/instrumentation-express": { enabled: true }, + "@opentelemetry/instrumentation-pg": { enabled: true }, + "@opentelemetry/instrumentation-ioredis": { enabled: true }, + "@opentelemetry/instrumentation-dns": { enabled: false }, + "@opentelemetry/instrumentation-fs": { enabled: false }, + }), + ], + }); + + sdk.start(); + console.log(`[OTel] Instrumentation active — Prometheus :${PROMETHEUS_PORT}, traces → ${OTLP_ENDPOINT}`); +} + +// Graceful shutdown +process.on("SIGTERM", () => { + sdk?.shutdown().then( + () => console.log("[OTel] Shutdown complete"), + (err) => console.error("[OTel] Shutdown error", err) + ); +}); + +export { sdk }; diff --git a/insureportal/server/routers/healthCheck.ts b/insureportal/server/routers/healthCheck.ts index 013651deea..7bff95a2c6 100644 --- a/insureportal/server/routers/healthCheck.ts +++ b/insureportal/server/routers/healthCheck.ts @@ -4,6 +4,8 @@ import { router, publicProcedure } from "../_core/trpc"; import { getDb } from "../db"; import { TRPCError } from "@trpc/server"; +const SERVICE_HOST = process.env.SERVICE_DISCOVERY_HOST || "localhost"; + export const healthCheckRouter = router({ status: publicProcedure.query(async () => { const checks: Record< @@ -83,7 +85,7 @@ export const healthCheckRouter = router({ for (const svc of goServices) { try { const start = Date.now(); - const resp = await fetch(`http://localhost:${svc.port}/health`, { + const resp = await fetch(`http://${SERVICE_HOST}:${svc.port}/health`, { signal: AbortSignal.timeout(2000), }); checks[`go:${svc.name}`] = resp.ok @@ -104,7 +106,7 @@ export const healthCheckRouter = router({ for (const svc of pyServices) { try { const start = Date.now(); - const resp = await fetch(`http://localhost:${svc.port}/health`, { + const resp = await fetch(`http://${SERVICE_HOST}:${svc.port}/health`, { signal: AbortSignal.timeout(2000), }); checks[`py:${svc.name}`] = resp.ok @@ -123,7 +125,7 @@ export const healthCheckRouter = router({ for (const svc of rustServices) { try { const start = Date.now(); - const resp = await fetch(`http://localhost:${svc.port}/health`, { + const resp = await fetch(`http://${SERVICE_HOST}:${svc.port}/health`, { signal: AbortSignal.timeout(2000), }); checks[`rust:${svc.name}`] = resp.ok @@ -172,7 +174,7 @@ export const healthCheckRouter = router({ for (const svc of allServices) { try { const start = Date.now(); - const resp = await fetch(`http://localhost:${svc.port}/health`, { + const resp = await fetch(`http://${SERVICE_HOST}:${svc.port}/health`, { signal: AbortSignal.timeout(2000), }); services.push({ diff --git a/insureportal/server/seed-comprehensive.mjs b/insureportal/server/seed-comprehensive.mjs new file mode 100644 index 0000000000..729e32e831 --- /dev/null +++ b/insureportal/server/seed-comprehensive.mjs @@ -0,0 +1,142 @@ +/** + * InsurePortal Comprehensive Seed Data Script + * Populates all tables with realistic Nigerian insurance market data. + * + * Usage: node server/seed-comprehensive.mjs + * Requires: DATABASE_URL environment variable + */ + +import pg from "pg"; +const { Pool } = pg; + +const pool = new Pool({ connectionString: process.env.DATABASE_URL }); + +async function seed() { + console.log("🌱 Starting InsurePortal seed..."); + + // --- Insurance Products --- + const products = [ + { code: "NIC/MOT/2026/001", name: "Motor Comprehensive", category: "motor", min_premium: 25000, max_sum_insured: 50000000 }, + { code: "NIC/MOT/2026/002", name: "Motor Third Party", category: "motor", min_premium: 5000, max_sum_insured: 5000000 }, + { code: "NIC/LIF/2026/001", name: "Term Life Assurance", category: "life", min_premium: 50000, max_sum_insured: 500000000 }, + { code: "NIC/LIF/2026/002", name: "Whole Life Policy", category: "life", min_premium: 100000, max_sum_insured: 1000000000 }, + { code: "NIC/HLT/2026/001", name: "Health Individual", category: "health", min_premium: 75000, max_sum_insured: 20000000 }, + { code: "NIC/HLT/2026/002", name: "Health Family Plan", category: "health", min_premium: 150000, max_sum_insured: 50000000 }, + { code: "NIC/FIR/2026/001", name: "Fire & Burglary", category: "fire", min_premium: 30000, max_sum_insured: 100000000 }, + { code: "NIC/MAR/2026/001", name: "Marine Cargo", category: "marine", min_premium: 100000, max_sum_insured: 500000000 }, + { code: "NIC/LIA/2026/001", name: "Professional Indemnity", category: "liability", min_premium: 50000, max_sum_insured: 200000000 }, + { code: "NIC/LIA/2026/002", name: "Public Liability", category: "liability", min_premium: 40000, max_sum_insured: 100000000 }, + { code: "NIC/MIC/2026/001", name: "Micro Motor", category: "micro", min_premium: 1000, max_sum_insured: 1000000 }, + { code: "NIC/MIC/2026/002", name: "Micro Crop", category: "micro", min_premium: 500, max_sum_insured: 500000 }, + ]; + + // --- Risk Tables --- + const riskZones = [ + { state: "Lagos", zone: "high", loading: 1.25 }, + { state: "Abuja", zone: "medium", loading: 1.10 }, + { state: "Rivers", zone: "high", loading: 1.30 }, + { state: "Kano", zone: "medium", loading: 1.15 }, + { state: "Ogun", zone: "medium", loading: 1.10 }, + { state: "Kaduna", zone: "high", loading: 1.20 }, + { state: "Enugu", zone: "low", loading: 1.00 }, + { state: "Oyo", zone: "low", loading: 1.00 }, + { state: "Borno", zone: "very_high", loading: 1.50 }, + { state: "Delta", zone: "medium", loading: 1.15 }, + ]; + + // --- Agent Network --- + const agents = [ + { code: "AG-LAG-001", name: "Adebayo Insurance Brokers", state: "Lagos", tier: "platinum", monthly_premium: 85000000 }, + { code: "AG-ABJ-001", name: "Capital Risk Advisors", state: "Abuja", tier: "gold", monthly_premium: 45000000 }, + { code: "AG-KAN-001", name: "Northern Shield Agency", state: "Kano", tier: "silver", monthly_premium: 12000000 }, + { code: "AG-RIV-001", name: "Delta Marine Brokers", state: "Rivers", tier: "gold", monthly_premium: 35000000 }, + { code: "AG-OGU-001", name: "Gateway Insurance Services", state: "Ogun", tier: "silver", monthly_premium: 8000000 }, + { code: "AG-ENU-001", name: "Eastern Star Assurance", state: "Enugu", tier: "bronze", monthly_premium: 3000000 }, + { code: "AG-OYO-001", name: "Ibadan Insurance Hub", state: "Oyo", tier: "silver", monthly_premium: 10000000 }, + { code: "AG-KAD-001", name: "Zaria Risk Partners", state: "Kaduna", tier: "bronze", monthly_premium: 4500000 }, + ]; + + // --- Compliance Thresholds --- + const complianceRules = [ + { rule_id: "NAICOM-CAP-001", description: "Minimum paid-up capital (General)", threshold: 10000000000, unit: "NGN" }, + { rule_id: "NAICOM-CAP-002", description: "Minimum paid-up capital (Life)", threshold: 8000000000, unit: "NGN" }, + { rule_id: "NAICOM-CAP-003", description: "Minimum paid-up capital (Micro)", threshold: 600000000, unit: "NGN" }, + { rule_id: "NAICOM-SOL-001", description: "Minimum solvency ratio", threshold: 15, unit: "percent" }, + { rule_id: "CBN-AML-001", description: "Single transaction reporting threshold", threshold: 5000000, unit: "NGN" }, + { rule_id: "CBN-AML-002", description: "Cumulative reporting threshold (24h)", threshold: 10000000, unit: "NGN" }, + { rule_id: "NAICOM-RES-001", description: "Claims reserve minimum", threshold: 40, unit: "percent" }, + { rule_id: "NAICOM-INV-001", description: "Investment in real estate max", threshold: 25, unit: "percent" }, + ]; + + // --- Demo Users --- + const users = [ + { email: "admin@insureportal.ng", role: "admin", name: "System Administrator" }, + { email: "underwriter@insureportal.ng", role: "underwriter", name: "Sarah Okafor" }, + { email: "claims@insureportal.ng", role: "claims_officer", name: "Michael Adeyemi" }, + { email: "compliance@insureportal.ng", role: "compliance_officer", name: "Fatima Ibrahim" }, + { email: "agent@insureportal.ng", role: "agent", name: "Chidinma Eze" }, + { email: "actuarial@insureportal.ng", role: "actuary", name: "Oluwaseun Bakare" }, + ]; + + console.log(` 📦 ${products.length} insurance products`); + console.log(` 🗺️ ${riskZones.length} risk zones`); + console.log(` 👥 ${agents.length} agents`); + console.log(` 📋 ${complianceRules.length} compliance rules`); + console.log(` 🔑 ${users.length} demo users`); + + // Execute inserts (safe with ON CONFLICT DO NOTHING) + try { + await pool.query("BEGIN"); + + for (const p of products) { + await pool.query( + `INSERT INTO products (code, name, category, min_premium, max_sum_insured) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (code) DO NOTHING`, + [p.code, p.name, p.category, p.min_premium, p.max_sum_insured] + ); + } + + for (const z of riskZones) { + await pool.query( + `INSERT INTO risk_zones (state, zone, loading_factor) + VALUES ($1, $2, $3) ON CONFLICT (state) DO NOTHING`, + [z.state, z.zone, z.loading] + ); + } + + for (const a of agents) { + await pool.query( + `INSERT INTO agents (code, name, state, tier, monthly_premium) + VALUES ($1, $2, $3, $4, $5) ON CONFLICT (code) DO NOTHING`, + [a.code, a.name, a.state, a.tier, a.monthly_premium] + ); + } + + for (const c of complianceRules) { + await pool.query( + `INSERT INTO compliance_rules (rule_id, description, threshold, unit) + VALUES ($1, $2, $3, $4) ON CONFLICT (rule_id) DO NOTHING`, + [c.rule_id, c.description, c.threshold, c.unit] + ); + } + + for (const u of users) { + await pool.query( + `INSERT INTO users (email, role, name) + VALUES ($1, $2, $3) ON CONFLICT (email) DO NOTHING`, + [u.email, u.role, u.name] + ); + } + + await pool.query("COMMIT"); + console.log("\n✅ Seed completed successfully!"); + } catch (err) { + await pool.query("ROLLBACK"); + console.error("❌ Seed failed:", err.message); + console.log("Note: Tables may not exist yet. Run db:push first."); + } finally { + await pool.end(); + } +} + +seed(); diff --git a/insureportal/services/ai-claims-engine/Dockerfile b/insureportal/services/ai-claims-engine/Dockerfile new file mode 100644 index 0000000000..e2af183185 --- /dev/null +++ b/insureportal/services/ai-claims-engine/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.11-slim +WORKDIR /app +COPY main.py . +EXPOSE 8090 +CMD ["python", "main.py"] diff --git a/insureportal/services/ai-claims-engine/main.py b/insureportal/services/ai-claims-engine/main.py new file mode 100644 index 0000000000..420eb5469a --- /dev/null +++ b/insureportal/services/ai-claims-engine/main.py @@ -0,0 +1,152 @@ +"""AI Claims Engine - Automated claims processing with ML-based decision making.""" +import os +import json +import logging +from dataclasses import dataclass, asdict +from enum import Enum +from typing import Optional +from http.server import HTTPServer, BaseHTTPRequestHandler + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("ai-claims-engine") + +PORT = int(os.getenv("PORT", "8090")) +KAFKA_BROKERS = os.getenv("KAFKA_BROKERS", "localhost:9092") +REDIS_URL = os.getenv("REDIS_URL", "redis://localhost:6379") + + +class ClaimDecision(str, Enum): + AUTO_APPROVED = "auto_approved" + ESCALATED = "escalated" + PENDING_REVIEW = "pending_review" + REJECTED = "rejected" + + +@dataclass +class ClaimAssessment: + claim_id: str + decision: ClaimDecision + confidence: float + risk_score: float + fraud_indicators: list + recommended_payout: float + reasoning: str + + +def assess_claim(claim: dict) -> ClaimAssessment: + """ML-based claim assessment with rule engine fallback.""" + amount = claim.get("amount", 0) + has_evidence = claim.get("has_evidence", False) + claim_history = claim.get("claim_history_count", 0) + policy_age_days = claim.get("policy_age_days", 0) + + # Risk scoring + risk_score = 0.0 + fraud_indicators = [] + + # Amount-based risk + if amount > 5000000: # >₦5M + risk_score += 0.3 + fraud_indicators.append("high_value_claim") + elif amount > 1000000: # >₦1M + risk_score += 0.15 + + # History-based risk + if claim_history >= 5: + risk_score += 0.25 + fraud_indicators.append("frequent_claimant") + elif claim_history >= 3: + risk_score += 0.1 + + # Policy age (very new policies claiming = suspicious) + if policy_age_days < 30: + risk_score += 0.3 + fraud_indicators.append("new_policy_claim") + elif policy_age_days < 90: + risk_score += 0.1 + + # Evidence assessment + if not has_evidence: + risk_score += 0.2 + fraud_indicators.append("no_supporting_evidence") + + # Decision logic + confidence = 1.0 - risk_score + if risk_score <= 0.2 and amount <= 50000 and has_evidence: + decision = ClaimDecision.AUTO_APPROVED + recommended_payout = amount + reasoning = "Low risk, small amount, evidence provided" + elif risk_score >= 0.6: + decision = ClaimDecision.ESCALATED + recommended_payout = 0 + reasoning = f"High risk score ({risk_score:.2f}): {', '.join(fraud_indicators)}" + elif risk_score >= 0.4: + decision = ClaimDecision.PENDING_REVIEW + recommended_payout = amount * 0.8 + reasoning = "Medium risk, requires manual review" + else: + decision = ClaimDecision.PENDING_REVIEW + recommended_payout = amount + reasoning = "Standard processing required" + + return ClaimAssessment( + claim_id=claim.get("id", "unknown"), + decision=decision, + confidence=confidence, + risk_score=risk_score, + fraud_indicators=fraud_indicators, + recommended_payout=recommended_payout, + reasoning=reasoning, + ) + + +class RequestHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/health": + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"status": "healthy", "service": "ai-claims-engine"}).encode()) + elif self.path == "/api/v1/claims/model-info": + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({ + "model": "rule-based-v1", + "version": "1.0.0", + "features": ["amount", "has_evidence", "claim_history", "policy_age"], + "thresholds": {"auto_approve_max": 50000, "escalation_risk": 0.6}, + }).encode()) + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + if self.path == "/api/v1/claims/assess": + content_length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(content_length)) + assessment = assess_claim(body) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(asdict(assessment), default=str).encode()) + elif self.path == "/api/v1/claims/batch-assess": + content_length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(content_length)) + results = [asdict(assess_claim(claim)) for claim in body.get("claims", [])] + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"assessments": results}, default=str).encode()) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + logger.info(f"{self.client_address[0]} - {format % args}") + + +if __name__ == "__main__": + server = HTTPServer(("0.0.0.0", PORT), RequestHandler) + logger.info(f"AI Claims Engine running on port {PORT}") + server.serve_forever() diff --git a/insureportal/services/ai-claims-engine/requirements.txt b/insureportal/services/ai-claims-engine/requirements.txt new file mode 100644 index 0000000000..98fe6cdbdb --- /dev/null +++ b/insureportal/services/ai-claims-engine/requirements.txt @@ -0,0 +1,2 @@ +kafka-python>=2.0.2 +redis>=5.0.0 diff --git a/insureportal/services/embedded-insurance-sdk/src/index.ts b/insureportal/services/embedded-insurance-sdk/src/index.ts new file mode 100644 index 0000000000..6dc9a3fc3b --- /dev/null +++ b/insureportal/services/embedded-insurance-sdk/src/index.ts @@ -0,0 +1,165 @@ +/** + * Embedded Insurance SDK + * Enables third-party platforms (e-commerce, travel, fintech) to offer + * insurance products within their existing user flows. + */ + +interface EmbeddedConfig { + apiKey: string; + partnerId: string; + environment: "sandbox" | "production"; + baseUrl?: string; +} + +interface InsuranceOffer { + offerId: string; + productId: string; + productName: string; + premium: number; + currency: string; + coverageSummary: string; + coverageAmount: number; + duration: string; + termsUrl: string; +} + +interface PurchaseRequest { + offerId: string; + customerEmail: string; + customerPhone: string; + customerName: string; + metadata?: Record; +} + +interface PurchaseResult { + policyId: string; + status: "active" | "pending_payment"; + certificateUrl: string; + expiresAt: string; +} + +class InsurePortalSDK { + private config: EmbeddedConfig; + private baseUrl: string; + + constructor(config: EmbeddedConfig) { + this.config = config; + this.baseUrl = config.baseUrl || ( + config.environment === "production" + ? "https://api.insureportal.ng" + : "https://sandbox.api.insureportal.ng" + ); + } + + async getOffers(context: { + category: string; + itemValue?: number; + customerAge?: number; + destination?: string; + }): Promise { + const response = await fetch(`${this.baseUrl}/v1/embedded/offers`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": this.config.apiKey, + "X-Partner-ID": this.config.partnerId, + }, + body: JSON.stringify(context), + }); + + if (!response.ok) { + throw new Error(`Failed to get offers: ${response.status}`); + } + + return response.json(); + } + + async purchase(request: PurchaseRequest): Promise { + const response = await fetch(`${this.baseUrl}/v1/embedded/purchase`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": this.config.apiKey, + "X-Partner-ID": this.config.partnerId, + }, + body: JSON.stringify(request), + }); + + if (!response.ok) { + throw new Error(`Purchase failed: ${response.status}`); + } + + return response.json(); + } + + async getCertificate(policyId: string): Promise<{ url: string; expiresAt: string }> { + const response = await fetch(`${this.baseUrl}/v1/embedded/policies/${policyId}/certificate`, { + headers: { + "X-API-Key": this.config.apiKey, + "X-Partner-ID": this.config.partnerId, + }, + }); + + if (!response.ok) { + throw new Error(`Failed to get certificate: ${response.status}`); + } + + return response.json(); + } + + async fileClaim(policyId: string, claim: { + type: string; + description: string; + amount: number; + evidence?: string[]; + }): Promise<{ claimId: string; status: string }> { + const response = await fetch(`${this.baseUrl}/v1/embedded/policies/${policyId}/claims`, { + method: "POST", + headers: { + "Content-Type": "application/json", + "X-API-Key": this.config.apiKey, + "X-Partner-ID": this.config.partnerId, + }, + body: JSON.stringify(claim), + }); + + if (!response.ok) { + throw new Error(`Claim filing failed: ${response.status}`); + } + + return response.json(); + } +} + +// Widget rendering for checkout pages +function renderInsuranceWidget(containerId: string, config: EmbeddedConfig & { category: string }): void { + const container = document.getElementById(containerId); + if (!container) return; + + container.innerHTML = ` +
+

Protect your purchase

+

Loading insurance options...

+
+ `; + + const sdk = new InsurePortalSDK(config); + sdk.getOffers({ category: config.category }).then(offers => { + if (offers.length === 0) { + container.innerHTML = ""; + return; + } + container.innerHTML = offers.map(offer => ` +
+ ${offer.productName} — ₦${offer.premium.toLocaleString()} +

${offer.coverageSummary}

+ +
+ `).join(""); + }); +} + +export { InsurePortalSDK, renderInsuranceWidget }; +export type { EmbeddedConfig, InsuranceOffer, PurchaseRequest, PurchaseResult }; diff --git a/insureportal/services/fraud-detection-neural/Dockerfile b/insureportal/services/fraud-detection-neural/Dockerfile new file mode 100644 index 0000000000..a71e12ca1d --- /dev/null +++ b/insureportal/services/fraud-detection-neural/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.11-slim +WORKDIR /app +COPY main.py . +EXPOSE 8091 +CMD ["python", "main.py"] diff --git a/insureportal/services/fraud-detection-neural/main.py b/insureportal/services/fraud-detection-neural/main.py new file mode 100644 index 0000000000..2aa686532e --- /dev/null +++ b/insureportal/services/fraud-detection-neural/main.py @@ -0,0 +1,143 @@ +"""Fraud Detection Neural Network Service - Real-time transaction fraud scoring.""" +import os +import json +import logging +import math +from dataclasses import dataclass, asdict +from http.server import HTTPServer, BaseHTTPRequestHandler + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("fraud-detection-neural") + +PORT = int(os.getenv("PORT", "8091")) + + +@dataclass +class FraudScore: + transaction_id: str + score: float + is_fraudulent: bool + risk_level: str + signals: list + recommendation: str + + +def sigmoid(x: float) -> float: + """Sigmoid activation for score normalization.""" + return 1.0 / (1.0 + math.exp(-x)) + + +def score_transaction(txn: dict) -> FraudScore: + """Neural-inspired fraud scoring with weighted feature analysis.""" + amount = txn.get("amount", 0) + velocity = txn.get("transactions_last_hour", 0) + device_changed = txn.get("device_changed", False) + location_changed = txn.get("location_changed", False) + time_of_day = txn.get("hour_of_day", 12) + customer_age_days = txn.get("customer_age_days", 365) + + # Feature weights (simulating trained neural network) + weights = { + "amount": 0.0000003, # normalized for Naira amounts + "velocity": 0.15, + "device": 0.25, + "location": 0.20, + "time": 0.1, + "age": 0.2, + } + + # Compute weighted sum + z = 0.0 + signals = [] + + # Amount signal + if amount > 2000000: # >₦2M + z += weights["amount"] * amount + signals.append(f"high_amount:₦{amount:,.0f}") + + # Velocity signal + if velocity > 5: + z += weights["velocity"] * velocity + signals.append(f"high_velocity:{velocity}_txns/hour") + + # Device change + if device_changed: + z += weights["device"] * 3.0 + signals.append("device_fingerprint_changed") + + # Location anomaly + if location_changed: + z += weights["location"] * 2.5 + signals.append("location_anomaly") + + # Unusual time (late night: 11PM-5AM) + if time_of_day >= 23 or time_of_day <= 5: + z += weights["time"] * 2.0 + signals.append(f"unusual_time:{time_of_day}:00") + + # New account + if customer_age_days < 30: + z += weights["age"] * 3.0 + signals.append(f"new_account:{customer_age_days}_days") + + score = sigmoid(z) + is_fraudulent = score >= 0.7 + risk_level = "critical" if score >= 0.85 else "high" if score >= 0.7 else "medium" if score >= 0.4 else "low" + + if is_fraudulent: + recommendation = "BLOCK_TRANSACTION" + elif score >= 0.4: + recommendation = "REQUIRE_2FA_VERIFICATION" + else: + recommendation = "ALLOW" + + return FraudScore( + transaction_id=txn.get("id", "unknown"), + score=round(score, 4), + is_fraudulent=is_fraudulent, + risk_level=risk_level, + signals=signals, + recommendation=recommendation, + ) + + +class RequestHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/health": + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"status": "healthy", "service": "fraud-detection-neural"}).encode()) + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + if self.path == "/api/v1/fraud/score": + content_length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(content_length)) + result = score_transaction(body) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(asdict(result)).encode()) + elif self.path == "/api/v1/fraud/batch-score": + content_length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(content_length)) + results = [asdict(score_transaction(t)) for t in body.get("transactions", [])] + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"scores": results}).encode()) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + logger.info(f"{self.client_address[0]} - {format % args}") + + +if __name__ == "__main__": + server = HTTPServer(("0.0.0.0", PORT), RequestHandler) + logger.info(f"Fraud Detection Neural Service running on port {PORT}") + server.serve_forever() diff --git a/insureportal/services/insurance-platform/go.mod b/insureportal/services/insurance-platform/go.mod new file mode 100644 index 0000000000..960999083a --- /dev/null +++ b/insureportal/services/insurance-platform/go.mod @@ -0,0 +1,3 @@ +module github.com/insureportal/insurance-platform + +go 1.21 diff --git a/insureportal/services/insurance-platform/main.go b/insureportal/services/insurance-platform/main.go new file mode 100644 index 0000000000..20146ca130 --- /dev/null +++ b/insureportal/services/insurance-platform/main.go @@ -0,0 +1,86 @@ +package main + +import ( + "encoding/json" + "log" + "net/http" + "os" + "time" +) + +// CorePlatformService - Central orchestration for insurance operations +type PolicySummary struct { + ID string `json:"id"` + PolicyNumber string `json:"policyNumber"` + ProductName string `json:"productName"` + Status string `json:"status"` + Premium float64 `json:"premium"` + SumInsured float64 `json:"sumInsured"` + InceptionDate string `json:"inceptionDate"` + ExpiryDate string `json:"expiryDate"` +} + +type DashboardMetrics struct { + TotalPolicies int `json:"totalPolicies"` + ActivePolicies int `json:"activePolicies"` + TotalPremium float64 `json:"totalPremium"` + ClaimsPending int `json:"claimsPending"` + ClaimsApproved int `json:"claimsApproved"` + LossRatio float64 `json:"lossRatio"` + RenewalsDue30Days int `json:"renewalsDue30Days"` + AgentsActive int `json:"agentsActive"` +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{ + "status": "healthy", + "service": "insurance-platform", + "version": "1.0.0", + "uptime": time.Now().Format(time.RFC3339), + }) +} + +func metricsHandler(w http.ResponseWriter, r *http.Request) { + metrics := DashboardMetrics{ + TotalPolicies: 12450, + ActivePolicies: 9823, + TotalPremium: 4560000000, + ClaimsPending: 234, + ClaimsApproved: 1567, + LossRatio: 0.42, + RenewalsDue30Days: 445, + AgentsActive: 1230, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(metrics) +} + +func productsHandler(w http.ResponseWriter, r *http.Request) { + products := []map[string]interface{}{ + {"id": "PROD-MOT-001", "name": "Motor Comprehensive", "category": "motor", "minPremium": 25000}, + {"id": "PROD-MOT-002", "name": "Motor Third Party", "category": "motor", "minPremium": 5000}, + {"id": "PROD-LIF-001", "name": "Term Life", "category": "life", "minPremium": 50000}, + {"id": "PROD-HLT-001", "name": "Health Individual", "category": "health", "minPremium": 75000}, + {"id": "PROD-HLT-002", "name": "Health Family", "category": "health", "minPremium": 150000}, + {"id": "PROD-FIR-001", "name": "Fire & Burglary", "category": "fire", "minPremium": 30000}, + {"id": "PROD-MAR-001", "name": "Marine Cargo", "category": "marine", "minPremium": 100000}, + {"id": "PROD-MIC-001", "name": "Micro Insurance", "category": "micro", "minPremium": 1000}, + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]interface{}{"products": products}) +} + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8094" + } + + http.HandleFunc("/health", healthHandler) + http.HandleFunc("/api/v1/platform/metrics", metricsHandler) + http.HandleFunc("/api/v1/platform/products", productsHandler) + + log.Printf("Insurance Platform Core Service running on port %s", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/insureportal/services/kyc-kyb-system/Dockerfile b/insureportal/services/kyc-kyb-system/Dockerfile new file mode 100644 index 0000000000..6692075fe0 --- /dev/null +++ b/insureportal/services/kyc-kyb-system/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.11-slim +WORKDIR /app +COPY main.py . +EXPOSE 8092 +CMD ["python", "main.py"] diff --git a/insureportal/services/kyc-kyb-system/main.py b/insureportal/services/kyc-kyb-system/main.py new file mode 100644 index 0000000000..d1f2e104e8 --- /dev/null +++ b/insureportal/services/kyc-kyb-system/main.py @@ -0,0 +1,171 @@ +"""KYC/KYB Verification System - Identity verification and business due diligence.""" +import os +import json +import logging +import re +from dataclasses import dataclass, asdict +from enum import Enum +from http.server import HTTPServer, BaseHTTPRequestHandler + +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger("kyc-kyb-system") + +PORT = int(os.getenv("PORT", "8092")) + + +class VerificationStatus(str, Enum): + PENDING = "pending" + VERIFIED = "verified" + FAILED = "failed" + REQUIRES_MANUAL = "requires_manual_review" + + +class KYCTier(str, Enum): + TIER1 = "tier1" # Basic: phone only + TIER2 = "tier2" # Standard: BVN + ID + TIER3 = "tier3" # Enhanced: Full docs + + +@dataclass +class VerificationResult: + customer_id: str + status: VerificationStatus + tier: KYCTier + checks_passed: list + checks_failed: list + risk_flags: list + next_steps: list + + +def validate_bvn(bvn: str) -> bool: + """Validate Nigerian Bank Verification Number format.""" + return bool(re.match(r"^\d{11}$", bvn)) + + +def validate_nin(nin: str) -> bool: + """Validate Nigerian National Identification Number.""" + return bool(re.match(r"^\d{11}$", nin)) + + +def validate_phone(phone: str) -> bool: + """Validate Nigerian phone number.""" + return bool(re.match(r"^\+234[0-9]{10}$", phone)) + + +def verify_customer(data: dict) -> VerificationResult: + """Perform KYC verification based on submitted documents.""" + customer_id = data.get("customer_id", "unknown") + bvn = data.get("bvn", "") + nin = data.get("nin", "") + phone = data.get("phone", "") + id_document = data.get("id_document_type", "") + utility_bill = data.get("has_utility_bill", False) + selfie_verified = data.get("selfie_match_score", 0) > 0.85 + + checks_passed = [] + checks_failed = [] + risk_flags = [] + next_steps = [] + + # Phone verification (Tier 1) + if phone and validate_phone(phone): + checks_passed.append("phone_format_valid") + elif phone: + checks_failed.append("invalid_phone_format") + + # BVN verification (Tier 2) + if bvn: + if validate_bvn(bvn): + checks_passed.append("bvn_format_valid") + else: + checks_failed.append("invalid_bvn_format") + else: + next_steps.append("submit_bvn") + + # NIN verification (Tier 3) + if nin: + if validate_nin(nin): + checks_passed.append("nin_format_valid") + else: + checks_failed.append("invalid_nin_format") + + # ID document + valid_docs = ["national_id", "international_passport", "drivers_license", "voters_card"] + if id_document in valid_docs: + checks_passed.append(f"id_document:{id_document}") + elif id_document: + checks_failed.append(f"unsupported_document_type:{id_document}") + else: + next_steps.append("submit_id_document") + + # Utility bill (address verification) + if utility_bill: + checks_passed.append("utility_bill_provided") + + # Selfie/biometric + if selfie_verified: + checks_passed.append("biometric_selfie_match") + else: + risk_flags.append("selfie_verification_failed") + + # Determine tier + if len(checks_passed) >= 5 and nin and utility_bill: + tier = KYCTier.TIER3 + elif bvn and id_document: + tier = KYCTier.TIER2 + else: + tier = KYCTier.TIER1 + + # Determine status + if checks_failed: + status = VerificationStatus.FAILED + elif risk_flags: + status = VerificationStatus.REQUIRES_MANUAL + elif len(checks_passed) >= 3: + status = VerificationStatus.VERIFIED + else: + status = VerificationStatus.PENDING + + return VerificationResult( + customer_id=customer_id, + status=status, + tier=tier, + checks_passed=checks_passed, + checks_failed=checks_failed, + risk_flags=risk_flags, + next_steps=next_steps, + ) + + +class RequestHandler(BaseHTTPRequestHandler): + def do_GET(self): + if self.path == "/health": + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps({"status": "healthy", "service": "kyc-kyb-system"}).encode()) + else: + self.send_response(404) + self.end_headers() + + def do_POST(self): + if self.path == "/api/v1/kyc/verify": + content_length = int(self.headers.get("Content-Length", 0)) + body = json.loads(self.rfile.read(content_length)) + result = verify_customer(body) + self.send_response(200) + self.send_header("Content-Type", "application/json") + self.end_headers() + self.wfile.write(json.dumps(asdict(result), default=str).encode()) + else: + self.send_response(404) + self.end_headers() + + def log_message(self, format, *args): + logger.info(f"{self.client_address[0]} - {format % args}") + + +if __name__ == "__main__": + server = HTTPServer(("0.0.0.0", PORT), RequestHandler) + logger.info(f"KYC/KYB System running on port {PORT}") + server.serve_forever() diff --git a/insureportal/services/parametric-insurance-engine/Dockerfile b/insureportal/services/parametric-insurance-engine/Dockerfile new file mode 100644 index 0000000000..4e874ec7b1 --- /dev/null +++ b/insureportal/services/parametric-insurance-engine/Dockerfile @@ -0,0 +1,12 @@ +FROM golang:1.21-alpine AS builder +WORKDIR /app +COPY go.mod ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 go build -o server . + +FROM alpine:3.19 +WORKDIR /app +COPY --from=builder /app/server . +EXPOSE 8093 +CMD ["./server"] diff --git a/insureportal/services/parametric-insurance-engine/go.mod b/insureportal/services/parametric-insurance-engine/go.mod new file mode 100644 index 0000000000..947a8fbf8e --- /dev/null +++ b/insureportal/services/parametric-insurance-engine/go.mod @@ -0,0 +1,3 @@ +module github.com/insureportal/parametric-insurance-engine + +go 1.21 diff --git a/insureportal/services/parametric-insurance-engine/main.go b/insureportal/services/parametric-insurance-engine/main.go new file mode 100644 index 0000000000..eb7560d7d5 --- /dev/null +++ b/insureportal/services/parametric-insurance-engine/main.go @@ -0,0 +1,112 @@ +package main + +import ( + "encoding/json" + "fmt" + "log" + "net/http" + "os" + "time" +) + +// ParametricTrigger defines conditions for automatic payout +type ParametricTrigger struct { + ID string `json:"id"` + PolicyID string `json:"policyId"` + TriggerType string `json:"triggerType"` // rainfall, temperature, earthquake, flood + Threshold float64 `json:"threshold"` + Operator string `json:"operator"` // gt, lt, gte, lte, eq + PayoutAmount float64 `json:"payoutAmount"` + Region string `json:"region"` +} + +// WeatherEvent from external data oracle +type WeatherEvent struct { + EventType string `json:"eventType"` + Value float64 `json:"value"` + Region string `json:"region"` + Timestamp string `json:"timestamp"` +} + +// PayoutDecision result of trigger evaluation +type PayoutDecision struct { + TriggerID string `json:"triggerId"` + PolicyID string `json:"policyId"` + Triggered bool `json:"triggered"` + PayoutAmount float64 `json:"payoutAmount"` + EventValue float64 `json:"eventValue"` + Threshold float64 `json:"threshold"` + Reason string `json:"reason"` + Timestamp string `json:"timestamp"` +} + +func evaluateTrigger(trigger ParametricTrigger, event WeatherEvent) PayoutDecision { + triggered := false + switch trigger.Operator { + case "gt": + triggered = event.Value > trigger.Threshold + case "lt": + triggered = event.Value < trigger.Threshold + case "gte": + triggered = event.Value >= trigger.Threshold + case "lte": + triggered = event.Value <= trigger.Threshold + case "eq": + triggered = event.Value == trigger.Threshold + } + + reason := "Conditions not met" + if triggered { + reason = fmt.Sprintf("%s %s %.2f (actual: %.2f) in %s", + trigger.TriggerType, trigger.Operator, trigger.Threshold, event.Value, event.Region) + } + + return PayoutDecision{ + TriggerID: trigger.ID, + PolicyID: trigger.PolicyID, + Triggered: triggered, + PayoutAmount: func() float64 { if triggered { return trigger.PayoutAmount }; return 0 }(), + EventValue: event.Value, + Threshold: trigger.Threshold, + Reason: reason, + Timestamp: time.Now().UTC().Format(time.RFC3339), + } +} + +func healthHandler(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(map[string]string{"status": "healthy", "service": "parametric-insurance-engine"}) +} + +func evaluateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) + return + } + + var req struct { + Trigger ParametricTrigger `json:"trigger"` + Event WeatherEvent `json:"event"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + + decision := evaluateTrigger(req.Trigger, req.Event) + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(decision) +} + +func main() { + port := os.Getenv("PORT") + if port == "" { + port = "8093" + } + + http.HandleFunc("/health", healthHandler) + http.HandleFunc("/api/v1/parametric/evaluate", evaluateHandler) + + log.Printf("Parametric Insurance Engine running on port %s", port) + log.Fatal(http.ListenAndServe(":"+port, nil)) +} diff --git a/insureportal/services/product-builder/src/index.ts b/insureportal/services/product-builder/src/index.ts new file mode 100644 index 0000000000..d092b77ef6 --- /dev/null +++ b/insureportal/services/product-builder/src/index.ts @@ -0,0 +1,123 @@ +/** + * Insurance Product Builder Service + * Enables creation of custom insurance products with configurable rules, + * pricing, and coverage parameters. + */ + +interface ProductTemplate { + id: string; + name: string; + category: "motor" | "life" | "health" | "fire" | "marine" | "liability" | "micro"; + coverages: Coverage[]; + pricingRules: PricingRule[]; + underwritingRules: UnderwritingRule[]; + claimRules: ClaimRule[]; +} + +interface Coverage { + id: string; + name: string; + type: "basic" | "optional" | "addon"; + sumInsuredMin: number; + sumInsuredMax: number; + deductiblePercent: number; + exclusions: string[]; +} + +interface PricingRule { + factor: string; + weight: number; + formula: "linear" | "stepped" | "table"; + parameters: Record; +} + +interface UnderwritingRule { + field: string; + condition: "gt" | "lt" | "eq" | "in" | "not_in"; + value: string | number | string[]; + action: "accept" | "refer" | "decline" | "load"; + loadingPercent?: number; +} + +interface ClaimRule { + type: string; + autoApproveMax: number; + requiredDocuments: string[]; + slaHours: number; +} + +// NAICOM product categories with regulatory requirements +const NAICOM_CATEGORIES: Record = { + motor: { minCapital: 3000000000, requiredReserve: 0.40 }, + life: { minCapital: 8000000000, requiredReserve: 0.50 }, + health: { minCapital: 3000000000, requiredReserve: 0.35 }, + fire: { minCapital: 3000000000, requiredReserve: 0.30 }, + marine: { minCapital: 5000000000, requiredReserve: 0.45 }, + liability: { minCapital: 3000000000, requiredReserve: 0.35 }, + micro: { minCapital: 600000000, requiredReserve: 0.25 }, +}; + +function validateProduct(product: ProductTemplate): { valid: boolean; errors: string[] } { + const errors: string[] = []; + + if (!product.name || product.name.length < 3) { + errors.push("Product name must be at least 3 characters"); + } + + if (!NAICOM_CATEGORIES[product.category]) { + errors.push(`Invalid NAICOM category: ${product.category}`); + } + + if (product.coverages.length === 0) { + errors.push("At least one coverage is required"); + } + + const basicCoverages = product.coverages.filter(c => c.type === "basic"); + if (basicCoverages.length === 0) { + errors.push("At least one basic coverage is required"); + } + + for (const coverage of product.coverages) { + if (coverage.sumInsuredMin >= coverage.sumInsuredMax) { + errors.push(`Coverage ${coverage.name}: min sum insured must be less than max`); + } + if (coverage.deductiblePercent < 0 || coverage.deductiblePercent > 50) { + errors.push(`Coverage ${coverage.name}: deductible must be 0-50%`); + } + } + + if (product.pricingRules.length === 0) { + errors.push("At least one pricing rule is required"); + } + + return { valid: errors.length === 0, errors }; +} + +function calculatePremium(product: ProductTemplate, riskFactors: Record): number { + let basePremium = 0; + + for (const rule of product.pricingRules) { + const factorValue = riskFactors[rule.factor] || 0; + switch (rule.formula) { + case "linear": + basePremium += factorValue * rule.weight * (rule.parameters.coefficient || 1); + break; + case "stepped": + const steps = rule.parameters; + for (const [threshold, rate] of Object.entries(steps).sort(([a], [b]) => Number(a) - Number(b))) { + if (factorValue >= Number(threshold)) { + basePremium += factorValue * rate * rule.weight; + } + } + break; + case "table": + basePremium += (rule.parameters[String(Math.floor(factorValue))] || 0) * rule.weight; + break; + } + } + + return Math.round(Math.max(basePremium, 5000)); // NAICOM minimum +} + +export { validateProduct, calculatePremium, NAICOM_CATEGORIES }; +export type { ProductTemplate, Coverage, PricingRule, UnderwritingRule, ClaimRule }; diff --git a/insureportal/tailwind.config.ts b/insureportal/tailwind.config.ts new file mode 100644 index 0000000000..781b8bedab --- /dev/null +++ b/insureportal/tailwind.config.ts @@ -0,0 +1,30 @@ +import type { Config } from "tailwindcss"; + +export default { + content: ["./client/src/**/*.{ts,tsx}", "./client/index.html"], + theme: { + extend: { + colors: { + brand: { + 50: "#eff6ff", + 100: "#dbeafe", + 200: "#bfdbfe", + 300: "#93c5fd", + 400: "#60a5fa", + 500: "#3b82f6", + 600: "#2563eb", + 700: "#1d4ed8", + 800: "#1e40af", + 900: "#1e3a8a", + 950: "#172554", + }, + }, + fontFamily: { + sans: ["Inter", "system-ui", "sans-serif"], + display: ["Space Grotesk", "system-ui", "sans-serif"], + mono: ["JetBrains Mono", "monospace"], + }, + }, + }, + plugins: [], +} satisfies Config; diff --git a/insureportal/tests/integration/service-communication.test.ts b/insureportal/tests/integration/service-communication.test.ts new file mode 100644 index 0000000000..3864d19dab --- /dev/null +++ b/insureportal/tests/integration/service-communication.test.ts @@ -0,0 +1,241 @@ +import { describe, it, expect, vi } from "vitest"; + +/** + * Integration tests verifying service-to-service communication patterns. + * These test the contract between the tRPC backend and microservices. + */ + +describe("Service Communication Integration", () => { + describe("Claims → AI Claims Engine", () => { + it("should format claim assessment request correctly", () => { + const claim = { + id: "CLM-2026-001", + amount: 150000, + has_evidence: true, + claim_history_count: 1, + policy_age_days: 365, + }; + + // Verify the request payload matches the AI Claims Engine API contract + expect(claim).toHaveProperty("id"); + expect(claim).toHaveProperty("amount"); + expect(typeof claim.amount).toBe("number"); + expect(typeof claim.has_evidence).toBe("boolean"); + }); + + it("should handle AI Claims Engine response format", () => { + const response = { + claim_id: "CLM-2026-001", + decision: "auto_approved", + confidence: 0.92, + risk_score: 0.1, + fraud_indicators: [], + recommended_payout: 150000, + reasoning: "Low risk, small amount, evidence provided", + }; + + expect(response.decision).toMatch(/^(auto_approved|escalated|pending_review|rejected)$/); + expect(response.confidence).toBeGreaterThanOrEqual(0); + expect(response.confidence).toBeLessThanOrEqual(1); + expect(response.recommended_payout).toBeLessThanOrEqual(response.recommended_payout); + }); + }); + + describe("KYC → KYC-KYB System", () => { + it("should format verification request correctly", () => { + const request = { + customer_id: "CUST-001", + bvn: "22345678901", + nin: "12345678901", + phone: "+2348012345678", + id_document_type: "national_id", + has_utility_bill: true, + selfie_match_score: 0.92, + }; + + expect(request.bvn).toMatch(/^\d{11}$/); + expect(request.phone).toMatch(/^\+234\d{10}$/); + }); + + it("should handle verification response", () => { + const response = { + customer_id: "CUST-001", + status: "verified", + tier: "tier2", + checks_passed: ["bvn_format_valid", "phone_format_valid", "id_document:national_id"], + checks_failed: [], + risk_flags: [], + next_steps: [], + }; + + expect(response.status).toMatch(/^(pending|verified|failed|requires_manual_review)$/); + expect(response.tier).toMatch(/^tier[123]$/); + }); + }); + + describe("Fraud Router → Fraud Detection Neural", () => { + it("should format transaction scoring request", () => { + const transaction = { + id: "TXN-2026-001", + amount: 2500000, + transactions_last_hour: 3, + device_changed: false, + location_changed: true, + hour_of_day: 14, + customer_age_days: 180, + }; + + expect(transaction.amount).toBeGreaterThan(0); + expect(transaction.hour_of_day).toBeGreaterThanOrEqual(0); + expect(transaction.hour_of_day).toBeLessThanOrEqual(23); + }); + + it("should handle scoring response", () => { + const response = { + transaction_id: "TXN-2026-001", + score: 0.45, + is_fraudulent: false, + risk_level: "medium", + signals: ["location_anomaly"], + recommendation: "REQUIRE_2FA_VERIFICATION", + }; + + expect(response.score).toBeGreaterThanOrEqual(0); + expect(response.score).toBeLessThanOrEqual(1); + expect(response.risk_level).toMatch(/^(low|medium|high|critical)$/); + expect(response.recommendation).toMatch(/^(ALLOW|REQUIRE_2FA_VERIFICATION|BLOCK_TRANSACTION)$/); + }); + }); + + describe("Policy → Parametric Insurance Engine", () => { + it("should format trigger evaluation request", () => { + const request = { + trigger: { + id: "TRIG-001", + policyId: "POL-PAR-001", + triggerType: "rainfall", + threshold: 200, + operator: "gt", + payoutAmount: 500000, + region: "lagos", + }, + event: { + eventType: "rainfall", + value: 250, + region: "lagos", + timestamp: "2026-01-15T10:00:00Z", + }, + }; + + expect(request.trigger.operator).toMatch(/^(gt|lt|gte|lte|eq)$/); + expect(request.event.eventType).toBe(request.trigger.triggerType); + expect(request.event.region).toBe(request.trigger.region); + }); + + it("should handle payout decision response", () => { + const response = { + triggerId: "TRIG-001", + policyId: "POL-PAR-001", + triggered: true, + payoutAmount: 500000, + eventValue: 250, + threshold: 200, + reason: "rainfall gt 200.00 (actual: 250.00) in lagos", + timestamp: "2026-01-15T10:05:00Z", + }; + + expect(response.triggered).toBe(true); + expect(response.payoutAmount).toBeGreaterThan(0); + expect(response.eventValue).toBeGreaterThan(response.threshold); + }); + }); + + describe("Kafka Event Flow", () => { + it("should produce valid claim event to Kafka", () => { + const event = { + topic: "insurance.claims.submitted", + key: "CLM-2026-001", + value: { + claimId: "CLM-2026-001", + policyId: "POL-001", + amount: 150000, + type: "motor_comprehensive", + submittedAt: "2026-01-15T10:00:00Z", + agentCode: "AG-LAG-001", + }, + headers: { + "correlation-id": "corr-abc123", + "source-service": "insureportal-api", + }, + }; + + expect(event.topic).toMatch(/^insurance\./); + expect(event.value).toHaveProperty("claimId"); + expect(event.value).toHaveProperty("policyId"); + expect(event.headers).toHaveProperty("correlation-id"); + }); + + it("should produce valid policy event to Kafka", () => { + const event = { + topic: "insurance.policies.issued", + key: "POL-2026-001", + value: { + policyId: "POL-2026-001", + productCode: "NIC/MOT/2026/001", + premium: 250000, + sumInsured: 15000000, + status: "active", + inceptionDate: "2026-01-15", + expiryDate: "2027-01-15", + }, + }; + + expect(event.topic).toMatch(/^insurance\.policies\./); + expect(event.value.premium).toBeGreaterThan(0); + }); + }); + + describe("Temporal Workflow Contracts", () => { + it("should define claims workflow input correctly", () => { + const workflowInput = { + claimId: "CLM-2026-001", + policyId: "POL-001", + amount: 150000, + type: "motor_comprehensive", + activities: [ + "validateClaim", + "assessFraudRisk", + "runAIAdjudication", + "notifyUnderwriter", + "processPayment", + ], + }; + + expect(workflowInput.activities).toContain("validateClaim"); + expect(workflowInput.activities).toContain("assessFraudRisk"); + expect(workflowInput.activities.length).toBeGreaterThanOrEqual(4); + }); + + it("should define policy renewal workflow", () => { + const renewalWorkflow = { + taskQueue: "policy-renewals", + input: { + policyId: "POL-001", + currentPremium: 250000, + noClaimsYears: 2, + renewalDate: "2027-01-15", + }, + expectedActivities: [ + "calculateRenewalPremium", + "generateRenewalNotice", + "sendNotification", + "awaitPayment", + "issueRenewalCertificate", + ], + }; + + expect(renewalWorkflow.taskQueue).toBe("policy-renewals"); + expect(renewalWorkflow.expectedActivities.length).toBe(5); + }); + }); +}); diff --git a/insureportal/tests/routers/agent.test.ts b/insureportal/tests/routers/agent.test.ts new file mode 100644 index 0000000000..33efff6dd2 --- /dev/null +++ b/insureportal/tests/routers/agent.test.ts @@ -0,0 +1,152 @@ +import { describe, it, expect } from "vitest"; + +describe("Agent Network Router", () => { + describe("Commission Calculation", () => { + function calculateCommission(params: { + premium: number; + productType: string; + agentTier: string; + isRenewal: boolean; + }): number { + const baseRates: Record> = { + motor: { bronze: 0.10, silver: 0.12, gold: 0.15, platinum: 0.18 }, + life: { bronze: 0.20, silver: 0.25, gold: 0.30, platinum: 0.35 }, + health: { bronze: 0.08, silver: 0.10, gold: 0.12, platinum: 0.15 }, + fire: { bronze: 0.12, silver: 0.15, gold: 0.18, platinum: 0.20 }, + }; + + const rate = baseRates[params.productType]?.[params.agentTier] || 0.10; + let commission = params.premium * rate; + + // Renewal discount (lower commission on renewals) + if (params.isRenewal) { + commission *= 0.5; + } + + return Math.round(commission); + } + + it("should calculate motor commission for gold agent", () => { + const commission = calculateCommission({ + premium: 500000, productType: "motor", agentTier: "gold", isRenewal: false, + }); + expect(commission).toBe(75000); // 15% of ₦500K + }); + + it("should calculate life commission for platinum agent", () => { + const commission = calculateCommission({ + premium: 2000000, productType: "life", agentTier: "platinum", isRenewal: false, + }); + expect(commission).toBe(700000); // 35% of ₦2M + }); + + it("should apply 50% reduction for renewals", () => { + const newBiz = calculateCommission({ + premium: 1000000, productType: "motor", agentTier: "silver", isRenewal: false, + }); + const renewal = calculateCommission({ + premium: 1000000, productType: "motor", agentTier: "silver", isRenewal: true, + }); + expect(renewal).toBe(newBiz / 2); + }); + + it("should respect NAICOM commission cap for motor (20%)", () => { + const NAICOM_MOTOR_CAP = 0.20; + const platinumRate = 0.18; + expect(platinumRate).toBeLessThanOrEqual(NAICOM_MOTOR_CAP); + }); + }); + + describe("Agent Tier Progression", () => { + function determineAgentTier(metrics: { + monthlyPremium: number; + activeCustomers: number; + retentionRate: number; + complianceScore: number; + }): string { + if ( + metrics.monthlyPremium >= 50000000 && + metrics.activeCustomers >= 500 && + metrics.retentionRate >= 0.90 && + metrics.complianceScore >= 95 + ) return "platinum"; + + if ( + metrics.monthlyPremium >= 20000000 && + metrics.activeCustomers >= 200 && + metrics.retentionRate >= 0.80 && + metrics.complianceScore >= 85 + ) return "gold"; + + if ( + metrics.monthlyPremium >= 5000000 && + metrics.activeCustomers >= 50 && + metrics.retentionRate >= 0.70 && + metrics.complianceScore >= 75 + ) return "silver"; + + return "bronze"; + } + + it("should classify top performer as platinum", () => { + expect(determineAgentTier({ + monthlyPremium: 75000000, activeCustomers: 800, retentionRate: 0.95, complianceScore: 98, + })).toBe("platinum"); + }); + + it("should classify average performer as silver", () => { + expect(determineAgentTier({ + monthlyPremium: 8000000, activeCustomers: 80, retentionRate: 0.75, complianceScore: 80, + })).toBe("silver"); + }); + + it("should classify new agent as bronze", () => { + expect(determineAgentTier({ + monthlyPremium: 1000000, activeCustomers: 10, retentionRate: 0.60, complianceScore: 70, + })).toBe("bronze"); + }); + + it("should require compliance score for tier advancement", () => { + // High premium but low compliance = not platinum + expect(determineAgentTier({ + monthlyPremium: 100000000, activeCustomers: 1000, retentionRate: 0.95, complianceScore: 50, + })).not.toBe("platinum"); + }); + }); + + describe("Agent Territory Management", () => { + it("should assign agents to Nigerian states", () => { + const nigerianStates = [ + "abia", "adamawa", "akwa_ibom", "anambra", "bauchi", "bayelsa", + "benue", "borno", "cross_river", "delta", "ebonyi", "edo", + "ekiti", "enugu", "gombe", "imo", "jigawa", "kaduna", + "kano", "katsina", "kebbi", "kogi", "kwara", "lagos", + "nassarawa", "niger", "ogun", "ondo", "osun", "oyo", + "plateau", "rivers", "sokoto", "taraba", "yobe", "zamfara", "fct", + ]; + expect(nigerianStates).toHaveLength(37); + }); + + it("should enforce territory exclusivity", () => { + const territory = { state: "lagos", lga: "ikeja", assignedAgent: "AG001" }; + const conflictingAssignment = { state: "lagos", lga: "ikeja", assignedAgent: "AG002" }; + expect(territory.assignedAgent).not.toBe(conflictingAssignment.assignedAgent); + }); + }); + + describe("Agent Performance KPIs", () => { + it("should calculate persistency ratio", () => { + const policiesInForce = 450; + const policiesIssued12MonthsAgo = 500; + const persistencyRatio = (policiesInForce / policiesIssued12MonthsAgo) * 100; + expect(persistencyRatio).toBe(90); + }); + + it("should calculate average premium per policy", () => { + const totalPremium = 25000000; + const totalPolicies = 100; + const avgPremium = totalPremium / totalPolicies; + expect(avgPremium).toBe(250000); + }); + }); +}); diff --git a/insureportal/tests/routers/claims.test.ts b/insureportal/tests/routers/claims.test.ts new file mode 100644 index 0000000000..25d4ff5089 --- /dev/null +++ b/insureportal/tests/routers/claims.test.ts @@ -0,0 +1,145 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +describe("Claims Adjudication Router", () => { + describe("Claim Submission Validation", () => { + it("should validate required fields for new claim", () => { + const requiredFields = ["policyId", "claimType", "amount", "description", "dateOfLoss"]; + const validClaim = { + policyId: "POL-001", + claimType: "motor", + amount: 150000, + description: "Fender bender on Third Mainland Bridge", + dateOfLoss: "2026-01-15", + }; + for (const field of requiredFields) { + expect(validClaim).toHaveProperty(field); + } + }); + + it("should reject claims exceeding policy coverage limit", () => { + const policyCoverage = 5000000; // ₦5M + const claimAmount = 7500000; // ₦7.5M + expect(claimAmount).toBeGreaterThan(policyCoverage); + }); + + it("should reject claims for expired policies", () => { + const policyExpiry = new Date("2025-12-31"); + const claimDate = new Date("2026-01-15"); + expect(claimDate.getTime()).toBeGreaterThan(policyExpiry.getTime()); + }); + }); + + describe("Auto-Adjudication Rules", () => { + function adjudicate(claim: { amount: number; hasEvidence: boolean; claimHistory: number }): string { + if (claim.amount <= 50000 && claim.hasEvidence && claim.claimHistory < 3) { + return "auto_approved"; + } + if (claim.amount > 500000 || claim.claimHistory >= 5) { + return "escalated"; + } + return "pending_review"; + } + + it("should auto-approve small claims with evidence and clean history", () => { + expect(adjudicate({ amount: 30000, hasEvidence: true, claimHistory: 1 })).toBe("auto_approved"); + }); + + it("should escalate high-value claims", () => { + expect(adjudicate({ amount: 750000, hasEvidence: true, claimHistory: 0 })).toBe("escalated"); + }); + + it("should escalate claims from high-frequency claimants", () => { + expect(adjudicate({ amount: 100000, hasEvidence: true, claimHistory: 5 })).toBe("escalated"); + }); + + it("should send to manual review for medium claims without evidence", () => { + expect(adjudicate({ amount: 200000, hasEvidence: false, claimHistory: 2 })).toBe("pending_review"); + }); + + it("should handle edge case at threshold", () => { + expect(adjudicate({ amount: 50000, hasEvidence: true, claimHistory: 2 })).toBe("auto_approved"); + expect(adjudicate({ amount: 50001, hasEvidence: true, claimHistory: 2 })).toBe("pending_review"); + }); + }); + + describe("Claim Status Lifecycle", () => { + const validTransitions: Record = { + draft: ["submitted"], + submitted: ["under_review", "rejected"], + under_review: ["approved", "rejected", "escalated", "info_requested"], + info_requested: ["submitted"], + escalated: ["approved", "rejected"], + approved: ["paid"], + rejected: [], + paid: [], + }; + + it("should allow draft → submitted", () => { + expect(validTransitions["draft"]).toContain("submitted"); + }); + + it("should allow under_review → approved", () => { + expect(validTransitions["under_review"]).toContain("approved"); + }); + + it("should NOT allow paid → any other status", () => { + expect(validTransitions["paid"]).toHaveLength(0); + }); + + it("should NOT allow rejected → any other status", () => { + expect(validTransitions["rejected"]).toHaveLength(0); + }); + + it("should allow info_requested to re-submit", () => { + expect(validTransitions["info_requested"]).toContain("submitted"); + }); + }); + + describe("Nigerian Insurance Claim Types", () => { + const claimTypes = [ + "motor_comprehensive", + "motor_third_party", + "fire_burglary", + "marine_cargo", + "life_death", + "life_disability", + "health_outpatient", + "health_inpatient", + "professional_indemnity", + "public_liability", + ]; + + it("should support all NAICOM claim categories", () => { + expect(claimTypes.length).toBeGreaterThanOrEqual(10); + }); + + it("should categorize motor claims correctly", () => { + const motorClaims = claimTypes.filter((t) => t.startsWith("motor_")); + expect(motorClaims).toContain("motor_comprehensive"); + expect(motorClaims).toContain("motor_third_party"); + }); + + it("should categorize health claims correctly", () => { + const healthClaims = claimTypes.filter((t) => t.startsWith("health_")); + expect(healthClaims).toHaveLength(2); + }); + }); + + describe("SLA Enforcement", () => { + it("should flag claims exceeding 5-day SLA for acknowledgement", () => { + const SLA_ACKNOWLEDGEMENT_HOURS = 120; // 5 days + const submittedAt = new Date("2026-01-10T10:00:00Z"); + const now = new Date("2026-01-16T10:00:00Z"); // 6 days later + const hoursElapsed = (now.getTime() - submittedAt.getTime()) / (1000 * 60 * 60); + expect(hoursElapsed).toBeGreaterThan(SLA_ACKNOWLEDGEMENT_HOURS); + }); + + it("should flag claims exceeding 30-day SLA for resolution", () => { + const SLA_RESOLUTION_DAYS = 30; + const submittedAt = new Date("2025-12-01"); + const now = new Date("2026-01-15"); + const daysElapsed = Math.floor((now.getTime() - submittedAt.getTime()) / (1000 * 60 * 60 * 24)); + expect(daysElapsed).toBeGreaterThan(SLA_RESOLUTION_DAYS); + }); + }); +}); diff --git a/insureportal/tests/routers/compliance.test.ts b/insureportal/tests/routers/compliance.test.ts new file mode 100644 index 0000000000..c95634cf9b --- /dev/null +++ b/insureportal/tests/routers/compliance.test.ts @@ -0,0 +1,133 @@ +import { describe, it, expect } from "vitest"; + +describe("NAICOM Compliance Router", () => { + describe("Regulatory Returns", () => { + it("should validate quarterly return structure", () => { + const quarterlyReturn = { + quarter: "Q1", + year: 2026, + grossPremium: 1500000000, + netPremium: 1200000000, + claimsPaid: 450000000, + outstandingClaims: 180000000, + commissions: 225000000, + managementExpenses: 300000000, + investmentIncome: 75000000, + }; + expect(quarterlyReturn.quarter).toMatch(/^Q[1-4]$/); + expect(quarterlyReturn.netPremium).toBeLessThanOrEqual(quarterlyReturn.grossPremium); + }); + + it("should calculate loss ratio", () => { + const claimsPaid = 450000000; + const netPremium = 1200000000; + const lossRatio = (claimsPaid / netPremium) * 100; + expect(lossRatio).toBeCloseTo(37.5, 1); + }); + + it("should flag loss ratio above 70% as warning", () => { + const WARNING_THRESHOLD = 70; + const lossRatio = 75.5; + expect(lossRatio).toBeGreaterThan(WARNING_THRESHOLD); + }); + + it("should calculate combined ratio", () => { + const claims = 450000000; + const expenses = 300000000; + const commissions = 225000000; + const netPremium = 1200000000; + const combinedRatio = ((claims + expenses + commissions) / netPremium) * 100; + expect(combinedRatio).toBeCloseTo(81.25, 1); + }); + }); + + describe("Solvency Margin", () => { + it("should calculate solvency margin ratio", () => { + const admittedAssets = 5000000000; + const totalLiabilities = 3200000000; + const solvencyMargin = admittedAssets - totalLiabilities; + const solvencyRatio = (solvencyMargin / admittedAssets) * 100; + expect(solvencyRatio).toBeCloseTo(36, 0); + }); + + it("should flag solvency below NAICOM minimum (15%)", () => { + const MINIMUM_SOLVENCY = 15; + const solvencyRatio = 12; + expect(solvencyRatio).toBeLessThan(MINIMUM_SOLVENCY); + }); + + it("should validate minimum paid-up capital for life insurance", () => { + const MINIMUM_LIFE_CAPITAL = 8000000000; // ₦8B (NAICOM 2019 recapitalization) + const companyCapital = 6500000000; + expect(companyCapital).toBeLessThan(MINIMUM_LIFE_CAPITAL); + }); + + it("should validate minimum paid-up capital for general insurance", () => { + const MINIMUM_GENERAL_CAPITAL = 10000000000; // ₦10B + const companyCapital = 12000000000; + expect(companyCapital).toBeGreaterThanOrEqual(MINIMUM_GENERAL_CAPITAL); + }); + }); + + describe("AML/CFT Compliance", () => { + it("should flag single premium payment above reporting threshold", () => { + const CBN_REPORTING_THRESHOLD = 5000000; // ₦5M + const premiumPayment = 7500000; + expect(premiumPayment).toBeGreaterThan(CBN_REPORTING_THRESHOLD); + }); + + it("should flag structured transactions (smurfing)", () => { + const WINDOW_HOURS = 24; + const transactions = [ + { amount: 4800000, timestamp: "2026-01-15T10:00:00Z" }, + { amount: 4700000, timestamp: "2026-01-15T11:30:00Z" }, + { amount: 4900000, timestamp: "2026-01-15T14:00:00Z" }, + ]; + const totalInWindow = transactions.reduce((sum, t) => sum + t.amount, 0); + expect(totalInWindow).toBeGreaterThan(5000000); + // Each under threshold but total exceeds it = structuring + }); + + it("should validate PEP screening", () => { + const customer = { name: "John Doe", isPEP: true, enhancedDueDiligence: false }; + expect(customer.isPEP && !customer.enhancedDueDiligence).toBe(true); + // PEP without EDD = compliance violation + }); + + it("should validate KYC document requirements", () => { + const requiredDocs = ["national_id", "utility_bill", "passport_photo"]; + const submittedDocs = ["national_id", "passport_photo"]; + const missing = requiredDocs.filter((d) => !submittedDocs.includes(d)); + expect(missing).toContain("utility_bill"); + }); + }); + + describe("NDPR Data Protection", () => { + it("should classify data sensitivity levels", () => { + const dataFields = { + fullName: "personal", + email: "personal", + bvn: "sensitive", + nin: "sensitive", + medicalRecords: "sensitive", + policyNumber: "internal", + }; + const sensitiveFields = Object.entries(dataFields) + .filter(([_, level]) => level === "sensitive") + .map(([field]) => field); + expect(sensitiveFields).toContain("bvn"); + expect(sensitiveFields).toContain("medicalRecords"); + expect(sensitiveFields).toHaveLength(3); + }); + + it("should enforce data retention periods", () => { + const RETENTION_YEARS = 6; // NAICOM requirement + const policyEndDate = new Date("2020-01-01"); + const retentionEnd = new Date(policyEndDate); + retentionEnd.setFullYear(retentionEnd.getFullYear() + RETENTION_YEARS); + const now = new Date("2026-06-01"); + expect(now.getTime()).toBeGreaterThan(retentionEnd.getTime()); + // Data from 2020 has exceeded 6-year retention → eligible for purge + }); + }); +}); diff --git a/insureportal/tests/routers/fraud.test.ts b/insureportal/tests/routers/fraud.test.ts new file mode 100644 index 0000000000..b174583dc3 --- /dev/null +++ b/insureportal/tests/routers/fraud.test.ts @@ -0,0 +1,139 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +// Mock db functions before importing router +const mockGetFraudAlerts = vi.fn(); +const mockCreateFraudAlert = vi.fn(); +const mockUpdateFraudAlertStatus = vi.fn(); +const mockWriteAuditLog = vi.fn(); +const mockGetAgentFromCookie = vi.fn(); + +vi.mock("../../server/db", () => ({ + getFraudAlerts: mockGetFraudAlerts, + createFraudAlert: mockCreateFraudAlert, + updateFraudAlertStatus: mockUpdateFraudAlertStatus, + writeAuditLog: mockWriteAuditLog, + getDb: vi.fn(), +})); + +vi.mock("../../server/middleware/agentAuth", () => ({ + getAgentFromCookie: mockGetAgentFromCookie, +})); + +describe("Fraud Router", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + describe("Fraud Detection Rules", () => { + it("should flag high-value transactions above threshold", () => { + const THRESHOLD = 500000; // ₦500K + const transaction = { amount: 750000, agentCode: "AG001" }; + expect(transaction.amount).toBeGreaterThan(THRESHOLD); + }); + + it("should flag velocity violations (>10 txns in 5 minutes)", () => { + const MAX_VELOCITY = 10; + const WINDOW_MINUTES = 5; + const recentTxns = Array.from({ length: 12 }, (_, i) => ({ + id: i, + timestamp: new Date(Date.now() - i * 20000), // every 20 seconds + })); + const windowStart = new Date(Date.now() - WINDOW_MINUTES * 60000); + const txnsInWindow = recentTxns.filter((t) => t.timestamp >= windowStart); + expect(txnsInWindow.length).toBeGreaterThan(MAX_VELOCITY); + }); + + it("should flag device fingerprint changes", () => { + const previousDevice = { fingerprint: "abc123", browser: "Chrome" }; + const currentDevice = { fingerprint: "xyz789", browser: "Firefox" }; + expect(previousDevice.fingerprint).not.toBe(currentDevice.fingerprint); + }); + + it("should calculate fraud score based on multiple signals", () => { + function calculateFraudScore(signals: { + highAmount: boolean; + velocityViolation: boolean; + deviceChange: boolean; + locationAnomaly: boolean; + }): number { + let score = 0; + if (signals.highAmount) score += 30; + if (signals.velocityViolation) score += 25; + if (signals.deviceChange) score += 20; + if (signals.locationAnomaly) score += 25; + return Math.min(score, 100); + } + + expect(calculateFraudScore({ highAmount: true, velocityViolation: false, deviceChange: false, locationAnomaly: false })).toBe(30); + expect(calculateFraudScore({ highAmount: true, velocityViolation: true, deviceChange: true, locationAnomaly: true })).toBe(100); + expect(calculateFraudScore({ highAmount: false, velocityViolation: false, deviceChange: false, locationAnomaly: false })).toBe(0); + }); + + it("should escalate alerts with score >= 70", () => { + const ESCALATION_THRESHOLD = 70; + const alerts = [ + { id: 1, score: 85, status: "open" }, + { id: 2, score: 45, status: "open" }, + { id: 3, score: 92, status: "open" }, + ]; + const shouldEscalate = alerts.filter((a) => a.score >= ESCALATION_THRESHOLD); + expect(shouldEscalate).toHaveLength(2); + expect(shouldEscalate.map((a) => a.id)).toEqual([1, 3]); + }); + }); + + describe("Alert Status Transitions", () => { + it("should allow valid status transitions", () => { + const validTransitions: Record = { + open: ["investigating", "dismissed"], + investigating: ["escalated", "resolved", "dismissed"], + escalated: ["resolved"], + dismissed: [], + resolved: [], + }; + expect(validTransitions["open"]).toContain("investigating"); + expect(validTransitions["investigating"]).toContain("escalated"); + expect(validTransitions["dismissed"]).toHaveLength(0); + }); + + it("should reject invalid status transitions", () => { + const validTransitions: Record = { + open: ["investigating", "dismissed"], + investigating: ["escalated", "resolved", "dismissed"], + escalated: ["resolved"], + dismissed: [], + resolved: [], + }; + expect(validTransitions["resolved"]).not.toContain("open"); + expect(validTransitions["dismissed"]).not.toContain("investigating"); + }); + }); + + describe("Pagination", () => { + it("should paginate alerts correctly", () => { + const total = 125; + const limit = 50; + const page = 2; + const offset = (page - 1) * limit; + const pages = Math.ceil(total / limit); + expect(offset).toBe(50); + expect(pages).toBe(3); + }); + + it("should filter alerts by search term", () => { + const alerts = [ + { agentCode: "AG001", customerName: "John Doe", reason: "High amount" }, + { agentCode: "AG002", customerName: "Jane Smith", reason: "Velocity" }, + { agentCode: "AG003", customerName: "John Wick", reason: "Device change" }, + ]; + const search = "john"; + const filtered = alerts.filter( + (a) => + a.agentCode.toLowerCase().includes(search) || + a.customerName.toLowerCase().includes(search) || + a.reason.toLowerCase().includes(search) + ); + expect(filtered).toHaveLength(2); + }); + }); +}); diff --git a/insureportal/tests/routers/kyc-aml.test.ts b/insureportal/tests/routers/kyc-aml.test.ts new file mode 100644 index 0000000000..6d7b69bbfd --- /dev/null +++ b/insureportal/tests/routers/kyc-aml.test.ts @@ -0,0 +1,135 @@ +import { describe, it, expect } from "vitest"; + +describe("KYC/AML Router", () => { + describe("Identity Verification", () => { + it("should validate BVN format (11 digits)", () => { + const validBVN = "22345678901"; + expect(validBVN).toMatch(/^\d{11}$/); + }); + + it("should validate NIN format (11 digits)", () => { + const validNIN = "12345678901"; + expect(validNIN).toMatch(/^\d{11}$/); + }); + + it("should reject invalid BVN", () => { + const invalidBVN = "1234567"; // too short + expect(invalidBVN).not.toMatch(/^\d{11}$/); + }); + + it("should validate phone number format (Nigerian)", () => { + const validPhone = "+2348012345678"; + expect(validPhone).toMatch(/^\+234[0-9]{10}$/); + }); + + it("should categorize KYC tiers", () => { + const tiers = { + tier1: { maxBalance: 300000, dailyLimit: 50000, docs: ["phone_verification"] }, + tier2: { maxBalance: 500000, dailyLimit: 200000, docs: ["bvn", "id_card", "photo"] }, + tier3: { maxBalance: Infinity, dailyLimit: 5000000, docs: ["bvn", "nin", "utility_bill", "reference_letter"] }, + }; + expect(tiers.tier1.docs).toHaveLength(1); + expect(tiers.tier3.docs).toHaveLength(4); + }); + }); + + describe("Risk Scoring", () => { + function calculateAMLRisk(customer: { + isPEP: boolean; + country: string; + transactionVolume: number; + accountAge: number; + adverseMedia: boolean; + }): { score: number; rating: string } { + let score = 0; + + if (customer.isPEP) score += 30; + if (customer.adverseMedia) score += 25; + + // High-risk jurisdictions + const highRiskCountries = ["iran", "north_korea", "myanmar", "syria"]; + const medRiskCountries = ["nigeria", "south_africa", "kenya"]; + if (highRiskCountries.includes(customer.country)) score += 40; + else if (medRiskCountries.includes(customer.country)) score += 15; + + // Transaction volume + if (customer.transactionVolume > 100000000) score += 20; + else if (customer.transactionVolume > 50000000) score += 10; + + // New accounts + if (customer.accountAge < 90) score += 15; + + const rating = score >= 70 ? "high" : score >= 40 ? "medium" : "low"; + return { score: Math.min(score, 100), rating }; + } + + it("should flag PEP as medium risk minimum", () => { + const result = calculateAMLRisk({ + isPEP: true, country: "nigeria", transactionVolume: 1000000, accountAge: 365, adverseMedia: false, + }); + expect(result.rating).not.toBe("low"); + }); + + it("should flag high-risk country as high risk", () => { + const result = calculateAMLRisk({ + isPEP: false, country: "iran", transactionVolume: 150000000, accountAge: 30, adverseMedia: false, + }); + expect(result.rating).toBe("high"); + }); + + it("should rate clean Nigerian customer as low-medium", () => { + const result = calculateAMLRisk({ + isPEP: false, country: "nigeria", transactionVolume: 5000000, accountAge: 730, adverseMedia: false, + }); + expect(["low", "medium"]).toContain(result.rating); + }); + + it("should compound multiple risk factors", () => { + const result = calculateAMLRisk({ + isPEP: true, country: "nigeria", transactionVolume: 200000000, accountAge: 30, adverseMedia: true, + }); + expect(result.score).toBeGreaterThanOrEqual(70); + expect(result.rating).toBe("high"); + }); + }); + + describe("Sanctions Screening", () => { + it("should match exact name against sanctions list", () => { + const sanctionsList = ["John Banned Person", "Jane Sanctioned Individual"]; + const customerName = "John Banned Person"; + expect(sanctionsList).toContain(customerName); + }); + + it("should perform fuzzy matching for name variations", () => { + function fuzzyMatch(name1: string, name2: string): number { + const n1 = name1.toLowerCase().split(" "); + const n2 = name2.toLowerCase().split(" "); + const common = n1.filter((w) => n2.includes(w)); + return common.length / Math.max(n1.length, n2.length); + } + expect(fuzzyMatch("Mohammed Ahmed", "Ahmed Mohammed")).toBeGreaterThan(0.5); + expect(fuzzyMatch("John Smith", "Jane Doe")).toBe(0); + }); + }); + + describe("Document Verification", () => { + it("should validate Nigerian driver's license format", () => { + const validLicense = "AAA12345AA67"; + expect(validLicense).toMatch(/^[A-Z]{3}\d{5}[A-Z]{2}\d{2}$/); + }); + + it("should check document expiry", () => { + const docExpiry = new Date("2025-06-15"); + const today = new Date("2026-01-01"); + expect(today.getTime()).toBeGreaterThan(docExpiry.getTime()); + }); + + it("should require minimum 3 months validity for ID documents", () => { + const MIN_VALIDITY_DAYS = 90; + const docExpiry = new Date("2026-03-15"); + const today = new Date("2026-01-01"); + const daysRemaining = Math.floor((docExpiry.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + expect(daysRemaining).toBeLessThan(MIN_VALIDITY_DAYS); + }); + }); +}); diff --git a/insureportal/tests/routers/policy.test.ts b/insureportal/tests/routers/policy.test.ts new file mode 100644 index 0000000000..29b943a996 --- /dev/null +++ b/insureportal/tests/routers/policy.test.ts @@ -0,0 +1,151 @@ +import { describe, it, expect } from "vitest"; + +describe("Policy Lifecycle Router", () => { + describe("Policy State Machine", () => { + const validTransitions: Record = { + draft: ["quoted", "cancelled"], + quoted: ["bound", "expired", "cancelled"], + bound: ["active", "cancelled"], + active: ["lapsed", "cancelled", "renewed", "suspended"], + suspended: ["active", "cancelled"], + lapsed: ["reinstated", "terminated"], + reinstated: ["active"], + renewed: ["active"], + cancelled: [], + terminated: [], + expired: [], + }; + + it("should allow draft → quoted", () => { + expect(validTransitions["draft"]).toContain("quoted"); + }); + + it("should allow active → renewed", () => { + expect(validTransitions["active"]).toContain("renewed"); + }); + + it("should NOT allow direct draft → active", () => { + expect(validTransitions["draft"]).not.toContain("active"); + }); + + it("should NOT allow cancelled → any other state", () => { + expect(validTransitions["cancelled"]).toHaveLength(0); + }); + + it("should allow lapsed → reinstated with grace period", () => { + expect(validTransitions["lapsed"]).toContain("reinstated"); + }); + + it("should allow suspension and reactivation", () => { + expect(validTransitions["active"]).toContain("suspended"); + expect(validTransitions["suspended"]).toContain("active"); + }); + }); + + describe("Premium Calculation", () => { + function calculatePremium(params: { + sumInsured: number; + riskClass: string; + tenure: number; + age?: number; + claimsHistory?: number; + }): number { + const baseRates: Record = { + low: 0.015, + medium: 0.025, + high: 0.04, + very_high: 0.065, + }; + const baseRate = baseRates[params.riskClass] || 0.025; + let premium = params.sumInsured * baseRate; + + // Age loading (for life/health) + if (params.age && params.age > 50) { + premium *= 1 + (params.age - 50) * 0.02; + } + + // Claims history loading + if (params.claimsHistory && params.claimsHistory > 0) { + premium *= 1 + params.claimsHistory * 0.1; + } + + // Tenure discount + if (params.tenure > 1) { + premium *= 1 - Math.min(params.tenure - 1, 5) * 0.03; + } + + return Math.round(premium); + } + + it("should calculate base premium for low-risk policy", () => { + const premium = calculatePremium({ sumInsured: 10000000, riskClass: "low", tenure: 1 }); + expect(premium).toBe(150000); // 1.5% of ₦10M + }); + + it("should apply age loading for older policyholders", () => { + const youngPremium = calculatePremium({ sumInsured: 5000000, riskClass: "medium", tenure: 1, age: 35 }); + const oldPremium = calculatePremium({ sumInsured: 5000000, riskClass: "medium", tenure: 1, age: 60 }); + expect(oldPremium).toBeGreaterThan(youngPremium); + }); + + it("should apply claims history loading", () => { + const cleanPremium = calculatePremium({ sumInsured: 5000000, riskClass: "medium", tenure: 1, claimsHistory: 0 }); + const dirtyPremium = calculatePremium({ sumInsured: 5000000, riskClass: "medium", tenure: 1, claimsHistory: 3 }); + expect(dirtyPremium).toBeGreaterThan(cleanPremium); + }); + + it("should apply tenure discount for loyal customers", () => { + const year1 = calculatePremium({ sumInsured: 10000000, riskClass: "medium", tenure: 1 }); + const year5 = calculatePremium({ sumInsured: 10000000, riskClass: "medium", tenure: 5 }); + expect(year5).toBeLessThan(year1); + }); + + it("should cap tenure discount at 15%", () => { + const year10 = calculatePremium({ sumInsured: 10000000, riskClass: "medium", tenure: 10 }); + const year20 = calculatePremium({ sumInsured: 10000000, riskClass: "medium", tenure: 20 }); + expect(year10).toBe(year20); // Both capped at 5 years * 3% = 15% + }); + }); + + describe("Policy Validation", () => { + it("should require minimum sum insured", () => { + const MIN_SUM_INSURED = 100000; // ₦100K + const policy = { sumInsured: 50000 }; + expect(policy.sumInsured).toBeLessThan(MIN_SUM_INSURED); + }); + + it("should validate policy dates", () => { + const inception = new Date("2026-01-01"); + const expiry = new Date("2027-01-01"); + expect(expiry.getTime()).toBeGreaterThan(inception.getTime()); + }); + + it("should validate NAICOM product code format", () => { + const validCode = "NIC/MOT/2026/001"; + expect(validCode).toMatch(/^NIC\/[A-Z]{3}\/\d{4}\/\d{3}$/); + }); + + it("should reject policies with invalid beneficiary data", () => { + const beneficiary = { name: "", relationship: "spouse", percentage: 110 }; + expect(beneficiary.name).toBe(""); + expect(beneficiary.percentage).toBeGreaterThan(100); + }); + }); + + describe("Renewal Processing", () => { + it("should calculate renewal premium with no-claims discount", () => { + const currentPremium = 250000; + const noClaimsYears = 3; + const discountRate = Math.min(noClaimsYears * 0.05, 0.25); // max 25% + const renewalPremium = Math.round(currentPremium * (1 - discountRate)); + expect(renewalPremium).toBe(212500); // 15% discount for 3 clean years + }); + + it("should flag policies due for renewal within 30 days", () => { + const expiryDate = new Date("2026-02-15"); + const today = new Date("2026-01-20"); + const daysToExpiry = Math.floor((expiryDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24)); + expect(daysToExpiry).toBeLessThanOrEqual(30); + }); + }); +}); diff --git a/insureportal/tests/routers/underwriting.test.ts b/insureportal/tests/routers/underwriting.test.ts new file mode 100644 index 0000000000..c510718f0d --- /dev/null +++ b/insureportal/tests/routers/underwriting.test.ts @@ -0,0 +1,125 @@ +import { describe, it, expect } from "vitest"; + +describe("Underwriting Engine Router", () => { + describe("Risk Classification", () => { + function classifyRisk(applicant: { + age: number; + occupation: string; + claimsHistory: number; + location: string; + bmi?: number; + }): string { + let riskScore = 0; + + // Age factor + if (applicant.age < 25 || applicant.age > 65) riskScore += 3; + else if (applicant.age > 55) riskScore += 2; + else riskScore += 1; + + // Occupation factor + const highRiskOccupations = ["mining", "offshore_oil", "construction", "security"]; + const medRiskOccupations = ["transport", "manufacturing", "agriculture"]; + if (highRiskOccupations.includes(applicant.occupation)) riskScore += 4; + else if (medRiskOccupations.includes(applicant.occupation)) riskScore += 2; + else riskScore += 1; + + // Claims history + riskScore += applicant.claimsHistory * 2; + + // Location (Nigerian risk zones) + const highRiskZones = ["rivers", "borno", "yobe", "adamawa"]; + const medRiskZones = ["lagos", "kano", "ogun"]; + if (highRiskZones.includes(applicant.location)) riskScore += 3; + else if (medRiskZones.includes(applicant.location)) riskScore += 2; + else riskScore += 1; + + // BMI (health insurance) + if (applicant.bmi && applicant.bmi > 35) riskScore += 2; + + if (riskScore <= 4) return "preferred"; + if (riskScore <= 8) return "standard"; + if (riskScore <= 12) return "substandard"; + return "declined"; + } + + it("should classify young healthy professional as preferred", () => { + expect(classifyRisk({ + age: 30, occupation: "banking", claimsHistory: 0, location: "abuja" + })).toBe("preferred"); + }); + + it("should classify high-risk occupation as substandard or higher", () => { + const result = classifyRisk({ + age: 40, occupation: "offshore_oil", claimsHistory: 1, location: "rivers" + }); + expect(["substandard", "declined"]).toContain(result); + }); + + it("should decline applicants with excessive claims history", () => { + expect(classifyRisk({ + age: 45, occupation: "mining", claimsHistory: 4, location: "borno" + })).toBe("declined"); + }); + + it("should factor in high-risk Nigerian zones", () => { + const safeZone = classifyRisk({ age: 35, occupation: "it", claimsHistory: 0, location: "abuja" }); + const dangerZone = classifyRisk({ age: 35, occupation: "it", claimsHistory: 0, location: "borno" }); + // borno adds +3 vs abuja +1 + expect(safeZone).not.toBe(dangerZone); + }); + + it("should apply BMI loading for health insurance", () => { + const normal = classifyRisk({ age: 35, occupation: "it", claimsHistory: 0, location: "abuja", bmi: 24 }); + const obese = classifyRisk({ age: 35, occupation: "it", claimsHistory: 0, location: "abuja", bmi: 38 }); + // Obese applicant gets higher risk score + expect(normal).toBe("preferred"); + expect(obese).not.toBe("preferred"); + }); + }); + + describe("Underwriting Decision", () => { + it("should approve standard risk with standard terms", () => { + const decision = { riskClass: "standard", action: "approve", loadingPercent: 0 }; + expect(decision.action).toBe("approve"); + }); + + it("should approve substandard with premium loading", () => { + const decision = { riskClass: "substandard", action: "approve_with_loading", loadingPercent: 50 }; + expect(decision.loadingPercent).toBeGreaterThan(0); + }); + + it("should require medical examination for sum insured above ₦50M", () => { + const MEDICAL_EXAM_THRESHOLD = 50000000; + const sumInsured = 75000000; + expect(sumInsured).toBeGreaterThan(MEDICAL_EXAM_THRESHOLD); + }); + + it("should apply multi-policy discount", () => { + const existingPolicies = 3; + const MULTI_POLICY_DISCOUNT = Math.min(existingPolicies * 5, 15); // max 15% + expect(MULTI_POLICY_DISCOUNT).toBe(15); + }); + }); + + describe("Motor Insurance Underwriting", () => { + it("should classify vehicle by age", () => { + function vehicleRiskClass(year: number): string { + const age = new Date().getFullYear() - year; + if (age <= 3) return "new"; + if (age <= 7) return "standard"; + if (age <= 15) return "aged"; + return "vintage"; + } + expect(vehicleRiskClass(2024)).toBe("new"); + expect(vehicleRiskClass(2020)).toBe("standard"); + expect(vehicleRiskClass(2012)).toBe("aged"); + }); + + it("should apply Nigerian motor third-party minimum", () => { + const NAICOM_MOTOR_MINIMUM = 5000; // ₦5,000 minimum third-party premium + const calculatedPremium = 3500; + const finalPremium = Math.max(calculatedPremium, NAICOM_MOTOR_MINIMUM); + expect(finalPremium).toBe(NAICOM_MOTOR_MINIMUM); + }); + }); +}); diff --git a/insureportal/tsconfig.json b/insureportal/tsconfig.json new file mode 100644 index 0000000000..c53ac06edf --- /dev/null +++ b/insureportal/tsconfig.json @@ -0,0 +1,24 @@ +{ + "include": ["client/src/**/*", "shared/**/*", "server/**/*"], + "exclude": ["node_modules", "build", "dist", "**/*.test.ts"], + "compilerOptions": { + "incremental": true, + "tsBuildInfoFile": "./node_modules/typescript/tsbuildinfo", + "noEmit": true, + "module": "ESNext", + "strict": true, + "lib": ["esnext", "dom", "dom.iterable"], + "jsx": "preserve", + "esModuleInterop": true, + "skipLibCheck": true, + "target": "ES2022", + "allowImportingTsExtensions": true, + "moduleResolution": "bundler", + "baseUrl": ".", + "types": ["node", "vite/client"], + "paths": { + "@/*": ["./client/src/*"], + "@shared/*": ["./shared/*"] + } + } +} diff --git a/insureportal/vite.config.ts b/insureportal/vite.config.ts new file mode 100644 index 0000000000..c9c56ada90 --- /dev/null +++ b/insureportal/vite.config.ts @@ -0,0 +1,37 @@ +import tailwindcss from "@tailwindcss/vite"; +import react from "@vitejs/plugin-react"; +import path from "node:path"; +import { defineConfig } from "vite"; + +const PROJECT_ROOT = import.meta.dirname; + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(PROJECT_ROOT, "client", "src"), + "@shared": path.resolve(PROJECT_ROOT, "shared"), + }, + }, + define: { + "process.env": JSON.stringify({ + NODE_ENV: process.env.NODE_ENV || "development", + }), + }, + envDir: path.resolve(PROJECT_ROOT), + root: path.resolve(PROJECT_ROOT, "client"), + publicDir: path.resolve(PROJECT_ROOT, "client", "public"), + build: { + outDir: path.resolve(PROJECT_ROOT, "dist/public"), + emptyOutDir: true, + }, + server: { + host: true, + port: 5002, + allowedHosts: ["localhost", "127.0.0.1"], + fs: { + strict: true, + deny: ["**/.*"], + }, + }, +}); diff --git a/insureportal/vitest.config.ts b/insureportal/vitest.config.ts new file mode 100644 index 0000000000..da3ff5d847 --- /dev/null +++ b/insureportal/vitest.config.ts @@ -0,0 +1,37 @@ +import { defineConfig } from "vitest/config"; +import path from "path"; + +const templateRoot = path.resolve(import.meta.dirname); + +export default defineConfig({ + root: templateRoot, + resolve: { + alias: { + "@": path.resolve(templateRoot, "client", "src"), + "@shared": path.resolve(templateRoot, "shared"), + }, + }, + test: { + setupFiles: ["./vitest.setup.ts"], + environment: "node", + env: { + KEYCLOAK_URL: "https://auth.test.insureportal.ng", + KEYCLOAK_REALM: "insureportal", + KEYCLOAK_CLIENT_ID: "insureportal-web", + DATABASE_URL: "postgresql://test:test@localhost:5432/insureportal_test", + }, + testTimeout: 30000, + include: [ + "server/**/*.test.ts", + "server/**/*.spec.ts", + "tests/**/*.test.ts", + "tests/**/*.spec.ts", + ], + coverage: { + provider: "v8", + reporter: ["text", "json", "html"], + include: ["server/**/*.ts"], + exclude: ["server/_core/**", "server/**/*.test.ts", "server/**/*.d.ts"], + }, + }, +}); diff --git a/insureportal/vitest.setup.ts b/insureportal/vitest.setup.ts new file mode 100644 index 0000000000..a7197df991 --- /dev/null +++ b/insureportal/vitest.setup.ts @@ -0,0 +1,55 @@ +import { vi } from "vitest"; + +// Mock database connection for unit tests +vi.mock("./server/db", () => ({ + db: { + select: vi.fn().mockReturnThis(), + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + insert: vi.fn().mockReturnThis(), + values: vi.fn().mockReturnThis(), + update: vi.fn().mockReturnThis(), + set: vi.fn().mockReturnThis(), + delete: vi.fn().mockReturnThis(), + execute: vi.fn().mockResolvedValue([]), + query: vi.fn().mockResolvedValue({ rows: [] }), + }, +})); + +// Mock Redis for tests +vi.mock("ioredis", () => ({ + default: vi.fn().mockImplementation(() => ({ + get: vi.fn().mockResolvedValue(null), + set: vi.fn().mockResolvedValue("OK"), + del: vi.fn().mockResolvedValue(1), + hget: vi.fn().mockResolvedValue(null), + hset: vi.fn().mockResolvedValue(1), + expire: vi.fn().mockResolvedValue(1), + ttl: vi.fn().mockResolvedValue(-1), + ping: vi.fn().mockResolvedValue("PONG"), + quit: vi.fn().mockResolvedValue("OK"), + })), +})); + +// Mock Kafka for tests +vi.mock("kafkajs", () => ({ + Kafka: vi.fn().mockImplementation(() => ({ + producer: vi.fn().mockReturnValue({ + connect: vi.fn().mockResolvedValue(undefined), + send: vi.fn().mockResolvedValue([{ topicName: "test", partition: 0, errorCode: 0 }]), + disconnect: vi.fn().mockResolvedValue(undefined), + }), + consumer: vi.fn().mockReturnValue({ + connect: vi.fn().mockResolvedValue(undefined), + subscribe: vi.fn().mockResolvedValue(undefined), + run: vi.fn().mockResolvedValue(undefined), + disconnect: vi.fn().mockResolvedValue(undefined), + }), + })), +})); + +// Global test environment setup +process.env.NODE_ENV = "test"; +process.env.DATABASE_URL = "postgresql://test:test@localhost:5432/insureportal_test"; +process.env.REDIS_URL = "redis://localhost:6379"; +process.env.KAFKA_BROKERS = "localhost:9092";