diff --git a/agent-network-platform/cmd/server/main.go b/agent-network-platform/cmd/server/main.go index 4494b48f8..da3e0db3f 100644 --- a/agent-network-platform/cmd/server/main.go +++ b/agent-network-platform/cmd/server/main.go @@ -1,12 +1,14 @@ package main import ( - "encoding/json" "fmt" "log" "net/http" "os" - "time" + + "agent-network-platform/internal/handlers" + "agent-network-platform/internal/repository" + "agent-network-platform/internal/service" ) func main() { @@ -14,222 +16,15 @@ func main() { if port == "" { port = "8093" } - + repo := repository.NewAgentRepository() + svc := service.NewAgentService(repo) + h := handlers.NewHandler(svc) mux := http.NewServeMux() - - mux.HandleFunc("/api/v1/agents", handleAgents) - mux.HandleFunc("/api/v1/agents/onboard", handleOnboard) - mux.HandleFunc("/api/v1/agents/territories", handleTerritories) - mux.HandleFunc("/api/v1/agents/leaderboard", handleLeaderboard) - mux.HandleFunc("/api/v1/agents/training", handleTraining) - mux.HandleFunc("/api/v1/agents/performance", handlePerformance) - mux.HandleFunc("/api/v1/agents/gamification", handleGamification) + h.RegisterRoutes(mux) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"agent-network-platform"}`)) - }) - - log.Printf("Agent Network Platform starting on port %s", port) - if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { - log.Fatal(err) - } -} - -// Agent represents an insurance sales agent -type Agent struct { - ID string `json:"id"` - Name string `json:"name"` - Phone string `json:"phone"` - Email string `json:"email"` - Status string `json:"status"` // pending, active, suspended, deactivated - Tier string `json:"tier"` // bronze, silver, gold, platinum - TerritoryID string `json:"territory_id"` - TotalPolicies int `json:"total_policies_sold"` - TotalPremium float64 `json:"total_premium_collected"` - CommissionEarned float64 `json:"commission_earned"` - Rating float64 `json:"rating"` - Badges []string `json:"badges"` - TrainingScore float64 `json:"training_score"` - JoinedAt time.Time `json:"joined_at"` - LastActive time.Time `json:"last_active"` - Location Location `json:"location"` -} - -// Location represents GPS coordinates -type Location struct { - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` - Address string `json:"address"` - LGA string `json:"lga"` - State string `json:"state"` -} - -// Territory represents an agent's assigned territory -type Territory struct { - ID string `json:"id"` - Name string `json:"name"` - State string `json:"state"` - LGAs []string `json:"lgas"` - AgentIDs []string `json:"agent_ids"` - Center Location `json:"center"` - Radius float64 `json:"radius_km"` -} - -// LeaderboardEntry for agent gamification -type LeaderboardEntry struct { - Rank int `json:"rank"` - AgentID string `json:"agent_id"` - AgentName string `json:"agent_name"` - PoliciesSold int `json:"policies_sold"` - PremiumCollected float64 `json:"premium_collected"` - Commission float64 `json:"commission"` - Points int `json:"points"` - Streak int `json:"streak_days"` - Tier string `json:"tier"` -} - -// TrainingModule for agent certification -type TrainingModule struct { - ID string `json:"id"` - Title string `json:"title"` - Description string `json:"description"` - Type string `json:"type"` // video, quiz, document - Duration int `json:"duration_minutes"` - Required bool `json:"required"` - Topics []string `json:"topics"` - PassScore float64 `json:"pass_score"` -} - -func handleAgents(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - agents := []Agent{ - { - ID: "AGT-001", Name: "Adebayo Ogundimu", Phone: "+2348012345678", - Status: "active", Tier: "gold", TotalPolicies: 87, - TotalPremium: 4350000, CommissionEarned: 652500, Rating: 4.8, - Badges: []string{"top_seller_q1", "100_percent_retention", "fast_closer"}, - Location: Location{Latitude: 6.5244, Longitude: 3.3792, State: "Lagos", LGA: "Ikeja"}, - }, - } - json.NewEncoder(w).Encode(map[string]interface{}{"agents": agents, "total": len(agents)}) -} - -func handleOnboard(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "agent_id": fmt.Sprintf("AGT-%d", time.Now().UnixNano()), - "status": "pending_verification", - "next_steps": []string{ - "Complete KYC verification", - "Complete mandatory training modules", - "Pass certification exam (score >= 70%)", - "Territory assignment", - }, - }) -} - -func handleTerritories(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "territories": []Territory{ - {ID: "TER-LAG-IKJ", Name: "Lagos - Ikeja", State: "Lagos", - LGAs: []string{"Ikeja", "Agege", "Ifako-Ijaiye"}, - Center: Location{Latitude: 6.6018, Longitude: 3.3515}, Radius: 15}, - {ID: "TER-LAG-VIC", Name: "Lagos - Victoria Island", State: "Lagos", - LGAs: []string{"Eti-Osa", "Lagos Island"}, - Center: Location{Latitude: 6.4281, Longitude: 3.4219}, Radius: 10}, - {ID: "TER-ABJ-CTR", Name: "Abuja - Central", State: "FCT", - LGAs: []string{"Municipal", "Gwagwalada"}, - Center: Location{Latitude: 9.0579, Longitude: 7.4951}, Radius: 20}, - }, - }) -} - -func handleLeaderboard(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "period": "2026-Q2", - "leaderboard": []LeaderboardEntry{ - {Rank: 1, AgentID: "AGT-001", AgentName: "Adebayo Ogundimu", - PoliciesSold: 87, PremiumCollected: 4350000, Commission: 652500, - Points: 2450, Streak: 23, Tier: "gold"}, - {Rank: 2, AgentID: "AGT-002", AgentName: "Chioma Nwosu", - PoliciesSold: 72, PremiumCollected: 3600000, Commission: 504000, - Points: 2100, Streak: 15, Tier: "gold"}, - {Rank: 3, AgentID: "AGT-003", AgentName: "Ibrahim Musa", - PoliciesSold: 65, PremiumCollected: 3250000, Commission: 422500, - Points: 1890, Streak: 8, Tier: "silver"}, - }, - }) -} - -func handleTraining(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "modules": []TrainingModule{ - {ID: "TRN-001", Title: "Insurance Fundamentals", Type: "video", - Duration: 30, Required: true, PassScore: 70, - Topics: []string{"What is insurance", "Types of insurance", "Nigerian insurance market"}}, - {ID: "TRN-002", Title: "Motor Insurance Products", Type: "quiz", - Duration: 20, Required: true, PassScore: 80, - Topics: []string{"Third party cover", "Comprehensive cover", "NMID requirements"}}, - {ID: "TRN-003", Title: "Life & Health Products", Type: "video", - Duration: 25, Required: true, PassScore: 70, - Topics: []string{"Term life", "Group life", "Hospital cash", "Funeral cover"}}, - {ID: "TRN-004", Title: "Sales Techniques", Type: "document", - Duration: 15, Required: false, PassScore: 60, - Topics: []string{"Consultative selling", "Objection handling", "Closing techniques"}}, - {ID: "TRN-005", Title: "KYC & Compliance", Type: "quiz", - Duration: 20, Required: true, PassScore: 90, - Topics: []string{"NAICOM rules", "AML/CFT", "Data protection", "Customer due diligence"}}, - }, - }) -} - -func handlePerformance(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "agent_id": "AGT-001", - "period": "2026-05", - "policies_sold": 12, - "premium_collected": 600000, - "commission_earned": 90000, - "conversion_rate": 0.42, - "avg_policy_value": 50000, - "customer_satisfaction": 4.7, - "targets": map[string]interface{}{ - "policies_target": 15, - "policies_progress": 0.80, - "premium_target": 750000, - "premium_progress": 0.80, - }, - }) -} - -func handleGamification(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "agent_id": "AGT-001", - "total_points": 2450, - "current_tier": "gold", - "next_tier": "platinum", - "points_to_next_tier": 550, - "streak_days": 23, - "badges": []map[string]interface{}{ - {"id": "top_seller_q1", "name": "Top Seller Q1 2026", "earned_at": "2026-04-01"}, - {"id": "100_pct_retention", "name": "100% Customer Retention", "earned_at": "2026-03-15"}, - {"id": "fast_closer", "name": "Fast Closer (avg < 2 days)", "earned_at": "2026-02-28"}, - {"id": "training_complete", "name": "All Training Complete", "earned_at": "2026-01-15"}, - }, - "challenges": []map[string]interface{}{ - {"id": "may_challenge", "title": "May Sales Sprint", "target": 20, - "current": 12, "reward_points": 500, "ends_at": "2026-05-31"}, - {"id": "referral_race", "title": "Referral Race", "target": 10, - "current": 4, "reward_points": 300, "ends_at": "2026-06-15"}, - }, + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"healthy","service":"agent-network-platform","version":"2.0.0"}`)) }) + log.Printf("Agent Network Platform v2.0 starting on port %s", port) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), mux)) } diff --git a/agent-network-platform/go.mod b/agent-network-platform/go.mod index 58e26ef6b..896ce80f2 100644 --- a/agent-network-platform/go.mod +++ b/agent-network-platform/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/agent-network-platform +module agent-network-platform go 1.22.0 diff --git a/agent-network-platform/internal/handlers/handlers.go b/agent-network-platform/internal/handlers/handlers.go new file mode 100644 index 000000000..2202595c1 --- /dev/null +++ b/agent-network-platform/internal/handlers/handlers.go @@ -0,0 +1,80 @@ +package handlers + +import ( + "agent-network-platform/internal/service" + "encoding/json" + "net/http" + "strings" +) + +type Handler struct { + svc *service.AgentService +} + +func NewHandler(svc *service.AgentService) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/agents/register", h.Register) + mux.HandleFunc("/api/v1/agents/verify/", h.Verify) + mux.HandleFunc("/api/v1/agents/agent/", h.GetAgent) + mux.HandleFunc("/api/v1/agents/list", h.ListAgents) + mux.HandleFunc("/api/v1/agents/sale", h.RecordSale) + mux.HandleFunc("/api/v1/agents/sales/", h.GetSales) + mux.HandleFunc("/api/v1/agents/stats", h.GetStats) +} + +func respondJSON(w http.ResponseWriter, s int, d interface{}) { + w.Header().Set("Content-Type", "application/json"); w.WriteHeader(s); json.NewEncoder(w).Encode(d) +} +func respondError(w http.ResponseWriter, s int, m string) { + respondJSON(w, s, map[string]string{"error": m}) +} + +func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { respondError(w, 405, "Method not allowed"); return } + var req service.RegisterRequest + json.NewDecoder(r.Body).Decode(&req) + a, err := h.svc.Register(req) + if err != nil { respondError(w, 400, err.Error()); return } + respondJSON(w, 201, a) +} + +func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { respondError(w, 405, "Method not allowed"); return } + id := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/verify/") + if err := h.svc.VerifyAgent(id); err != nil { respondError(w, 400, err.Error()); return } + respondJSON(w, 200, map[string]string{"status": "verified"}) +} + +func (h *Handler) GetAgent(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/agent/") + a, err := h.svc.GetAgent(id) + if err != nil { respondError(w, 404, err.Error()); return } + respondJSON(w, 200, a) +} + +func (h *Handler) ListAgents(w http.ResponseWriter, r *http.Request) { + region := r.URL.Query().Get("region"); status := r.URL.Query().Get("status") + agents := h.svc.ListAgents(region, status) + respondJSON(w, 200, map[string]interface{}{"agents": agents, "count": len(agents)}) +} + +func (h *Handler) RecordSale(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { respondError(w, 405, "Method not allowed"); return } + var req service.SaleRequest + json.NewDecoder(r.Body).Decode(&req) + sale, err := h.svc.RecordSale(req) + if err != nil { respondError(w, 400, err.Error()); return } + respondJSON(w, 201, sale) +} + +func (h *Handler) GetSales(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/agents/sales/") + respondJSON(w, 200, map[string]interface{}{"sales": h.svc.GetSales(id)}) +} + +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { + respondJSON(w, 200, h.svc.GetStats()) +} diff --git a/agent-network-platform/internal/models/agent.go b/agent-network-platform/internal/models/agent.go new file mode 100644 index 000000000..c32b4e9d9 --- /dev/null +++ b/agent-network-platform/internal/models/agent.go @@ -0,0 +1,78 @@ +package models + +import "time" + +type AgentStatus string + +const ( + AgentActive AgentStatus = "active" + AgentInactive AgentStatus = "inactive" + AgentSuspended AgentStatus = "suspended" + AgentPending AgentStatus = "pending_verification" +) + +type AgentTier string + +const ( + TierBronze AgentTier = "bronze" + TierSilver AgentTier = "silver" + TierGold AgentTier = "gold" + TierPlatinum AgentTier = "platinum" +) + +type Agent struct { + ID string `json:"id"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + NIN string `json:"nin"` + Region string `json:"region"` + State string `json:"state"` + LGA string `json:"lga"` + Address string `json:"address"` + Tier AgentTier `json:"tier"` + Status AgentStatus `json:"status"` + CommissionRate float64 `json:"commission_rate"` + TotalSales float64 `json:"total_sales"` + TotalCommission float64 `json:"total_commission"` + PoliciesSold int `json:"policies_sold"` + ActivePolicies int `json:"active_policies"` + ClaimsAssisted int `json:"claims_assisted"` + Rating float64 `json:"rating"` + LastActiveAt *time.Time `json:"last_active_at,omitempty"` + VerifiedAt *time.Time `json:"verified_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type AgentSale struct { + ID string `json:"id"` + AgentID string `json:"agent_id"` + PolicyID string `json:"policy_id"` + CustomerID string `json:"customer_id"` + Product string `json:"product"` + Premium float64 `json:"premium"` + Commission float64 `json:"commission"` + Status string `json:"status"` + Channel string `json:"channel"` + CreatedAt time.Time `json:"created_at"` +} + +type AgentTarget struct { + ID string `json:"id"` + AgentID string `json:"agent_id"` + Period string `json:"period"` + SalesTarget float64 `json:"sales_target"` + SalesActual float64 `json:"sales_actual"` + PolicyTarget int `json:"policy_target"` + PolicyActual int `json:"policy_actual"` + Achievement float64 `json:"achievement_pct"` +} + +type AgentTerritory struct { + ID string `json:"id"` + AgentID string `json:"agent_id"` + Region string `json:"region"` + States []string `json:"states"` + LGAs []string `json:"lgas"` + Exclusive bool `json:"exclusive"` +} diff --git a/agent-network-platform/internal/repository/repository.go b/agent-network-platform/internal/repository/repository.go new file mode 100644 index 000000000..b360585fb --- /dev/null +++ b/agent-network-platform/internal/repository/repository.go @@ -0,0 +1,115 @@ +package repository + +import ( + "agent-network-platform/internal/models" + "fmt" + "sync" + "time" +) + +type AgentRepository struct { + mu sync.RWMutex + agents map[string]*models.Agent + sales []models.AgentSale + targets map[string]*models.AgentTarget + territories map[string]*models.AgentTerritory +} + +func NewAgentRepository() *AgentRepository { + return &AgentRepository{ + agents: make(map[string]*models.Agent), + targets: make(map[string]*models.AgentTarget), + territories: make(map[string]*models.AgentTerritory), + } +} + +func (r *AgentRepository) Create(a *models.Agent) error { + r.mu.Lock() + defer r.mu.Unlock() + r.agents[a.ID] = a + return nil +} + +func (r *AgentRepository) GetByID(id string) (*models.Agent, error) { + r.mu.RLock() + defer r.mu.RUnlock() + a, ok := r.agents[id] + if !ok { + return nil, fmt.Errorf("agent %s not found", id) + } + return a, nil +} + +func (r *AgentRepository) Update(a *models.Agent) { + r.mu.Lock() + defer r.mu.Unlock() + r.agents[a.ID] = a +} + +func (r *AgentRepository) List(region, status string) []models.Agent { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.Agent + for _, a := range r.agents { + if region != "" && a.Region != region { continue } + if status != "" && string(a.Status) != status { continue } + result = append(result, *a) + } + return result +} + +func (r *AgentRepository) RecordSale(sale models.AgentSale) { + r.mu.Lock() + defer r.mu.Unlock() + r.sales = append(r.sales, sale) + if a, ok := r.agents[sale.AgentID]; ok { + a.TotalSales += sale.Premium + a.TotalCommission += sale.Commission + a.PoliciesSold++ + a.ActivePolicies++ + now := time.Now() + a.LastActiveAt = &now + } +} + +func (r *AgentRepository) GetSales(agentID string) []models.AgentSale { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.AgentSale + for _, s := range r.sales { + if s.AgentID == agentID { + result = append(result, s) + } + } + return result +} + +func (r *AgentRepository) SetTarget(t *models.AgentTarget) { + r.mu.Lock() + defer r.mu.Unlock() + r.targets[t.AgentID+"-"+t.Period] = t +} + +func (r *AgentRepository) GetTarget(agentID, period string) *models.AgentTarget { + r.mu.RLock() + defer r.mu.RUnlock() + return r.targets[agentID+"-"+period] +} + +func (r *AgentRepository) GetStats() map[string]interface{} { + r.mu.RLock() + defer r.mu.RUnlock() + active := 0 + totalSales := 0.0 + totalComm := 0.0 + for _, a := range r.agents { + if a.Status == models.AgentActive { active++ } + totalSales += a.TotalSales + totalComm += a.TotalCommission + } + return map[string]interface{}{ + "total_agents": len(r.agents), "active_agents": active, + "total_sales": totalSales, "total_commissions": totalComm, + "total_transactions": len(r.sales), + } +} diff --git a/agent-network-platform/internal/service/service.go b/agent-network-platform/internal/service/service.go new file mode 100644 index 000000000..2eff48678 --- /dev/null +++ b/agent-network-platform/internal/service/service.go @@ -0,0 +1,141 @@ +package service + +import ( + "agent-network-platform/internal/models" + "agent-network-platform/internal/repository" + "fmt" + "time" +) + +type AgentService struct { + repo *repository.AgentRepository +} + +func NewAgentService(repo *repository.AgentRepository) *AgentService { + return &AgentService{repo: repo} +} + +type RegisterRequest struct { + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + NIN string `json:"nin"` + Region string `json:"region"` + State string `json:"state"` + LGA string `json:"lga"` + Address string `json:"address"` +} + +func (s *AgentService) Register(req RegisterRequest) (*models.Agent, error) { + if req.Name == "" || req.Phone == "" { + return nil, fmt.Errorf("name and phone are required") + } + if req.NIN == "" { + return nil, fmt.Errorf("NIN is required for agent verification") + } + + tier, rate := s.assignInitialTier() + + agent := &models.Agent{ + ID: fmt.Sprintf("AGT-%d", time.Now().UnixNano()%10000000), + Name: req.Name, + Email: req.Email, + Phone: req.Phone, + NIN: req.NIN, + Region: req.Region, + State: req.State, + LGA: req.LGA, + Address: req.Address, + Tier: tier, + Status: models.AgentPending, + CommissionRate: rate, + Rating: 5.0, + CreatedAt: time.Now(), + } + + if err := s.repo.Create(agent); err != nil { + return nil, err + } + return agent, nil +} + +func (s *AgentService) assignInitialTier() (models.AgentTier, float64) { + return models.TierBronze, 0.05 +} + +type SaleRequest struct { + AgentID string `json:"agent_id"` + PolicyID string `json:"policy_id"` + CustomerID string `json:"customer_id"` + Product string `json:"product"` + Premium float64 `json:"premium"` + Channel string `json:"channel"` +} + +func (s *AgentService) RecordSale(req SaleRequest) (*models.AgentSale, error) { + agent, err := s.repo.GetByID(req.AgentID) + if err != nil { + return nil, err + } + if agent.Status != models.AgentActive && agent.Status != models.AgentPending { + return nil, fmt.Errorf("agent %s is not active", req.AgentID) + } + if req.Premium <= 0 { + return nil, fmt.Errorf("premium must be positive") + } + + commission := req.Premium * agent.CommissionRate + + sale := models.AgentSale{ + ID: fmt.Sprintf("SALE-%d", time.Now().UnixNano()%10000000), + AgentID: req.AgentID, + PolicyID: req.PolicyID, + CustomerID: req.CustomerID, + Product: req.Product, + Premium: req.Premium, + Commission: commission, + Status: "completed", + Channel: req.Channel, + CreatedAt: time.Now(), + } + s.repo.RecordSale(sale) + + s.checkTierUpgrade(agent) + + return &sale, nil +} + +func (s *AgentService) checkTierUpgrade(agent *models.Agent) { + var newTier models.AgentTier + var newRate float64 + switch { + case agent.TotalSales >= 10000000: + newTier, newRate = models.TierPlatinum, 0.12 + case agent.TotalSales >= 5000000: + newTier, newRate = models.TierGold, 0.10 + case agent.TotalSales >= 1000000: + newTier, newRate = models.TierSilver, 0.07 + default: + return + } + if newTier != agent.Tier { + agent.Tier = newTier + agent.CommissionRate = newRate + s.repo.Update(agent) + } +} + +func (s *AgentService) VerifyAgent(id string) error { + agent, err := s.repo.GetByID(id) + if err != nil { return err } + now := time.Now() + agent.Status = models.AgentActive + agent.VerifiedAt = &now + s.repo.Update(agent) + return nil +} + +func (s *AgentService) GetAgent(id string) (*models.Agent, error) { return s.repo.GetByID(id) } +func (s *AgentService) ListAgents(region, status string) []models.Agent { return s.repo.List(region, status) } +func (s *AgentService) GetSales(agentID string) []models.AgentSale { return s.repo.GetSales(agentID) } +func (s *AgentService) GetStats() map[string]interface{} { return s.repo.GetStats() } diff --git a/api-marketplace/cmd/server/main.go b/api-marketplace/cmd/server/main.go index 33e86d0a9..b9105fe7e 100644 --- a/api-marketplace/cmd/server/main.go +++ b/api-marketplace/cmd/server/main.go @@ -1,142 +1,24 @@ package main import ( - "encoding/json" - "fmt" - "log" - "net/http" - "os" - "time" + "fmt"; "log"; "net/http"; "os" + "api-marketplace/internal/handlers" + "api-marketplace/internal/repository" + "api-marketplace/internal/service" ) func main() { port := os.Getenv("PORT") - if port == "" { - port = "8111" - } + if port == "" { port = "8111" } + repo := repository.NewMarketplaceRepository() + svc := service.NewMarketplaceService(repo) + h := handlers.NewHandler(svc) mux := http.NewServeMux() - mux.HandleFunc("/api/v1/marketplace/apis", handleListAPIs) - mux.HandleFunc("/api/v1/marketplace/subscribe", handleSubscribe) - mux.HandleFunc("/api/v1/marketplace/usage", handleUsage) - mux.HandleFunc("/api/v1/marketplace/partners", handlePartners) - mux.HandleFunc("/api/v1/marketplace/sandbox", handleSandbox) + h.RegisterRoutes(mux) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"api-marketplace"}`)) - }) - log.Printf("API Marketplace starting on port %s", port) - if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { - log.Fatal(err) - } -} - -func handleListAPIs(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "apis": []map[string]interface{}{ - { - "id": "api-quote", "name": "Quote API", "version": "v1", - "description": "Get instant insurance quotes for all product types", - "category": "core", "pricing": "free_tier_1000", - "endpoints": []string{"POST /quotes", "GET /quotes/{id}"}, - "rate_limit": "1000/hour", - }, - { - "id": "api-policy", "name": "Policy Management API", "version": "v1", - "description": "Create, manage, and query insurance policies", - "category": "core", "pricing": "pay_per_use", - "endpoints": []string{"POST /policies", "GET /policies/{id}", "PUT /policies/{id}", "POST /policies/{id}/renew"}, - "rate_limit": "500/hour", - }, - { - "id": "api-claims", "name": "Claims API", "version": "v1", - "description": "File and track insurance claims with AI assessment", - "category": "core", "pricing": "pay_per_use", - "endpoints": []string{"POST /claims", "GET /claims/{id}", "POST /claims/{id}/documents"}, - "rate_limit": "200/hour", - }, - { - "id": "api-kyc", "name": "KYC Verification API", "version": "v1", - "description": "Identity verification across African countries", - "category": "verification", "pricing": "per_verification", - "endpoints": []string{"POST /verify", "GET /verify/{id}"}, - "rate_limit": "100/hour", - }, - { - "id": "api-payments", "name": "Payment Integration API", "version": "v1", - "description": "Mobile money, bank transfer, and card payment integration", - "category": "financial", "pricing": "transaction_fee", - "endpoints": []string{"POST /payments", "GET /payments/{id}", "POST /payouts"}, - "rate_limit": "500/hour", - }, - { - "id": "api-embedded", "name": "Embedded Insurance SDK API", "version": "v1", - "description": "White-label insurance for B2B2C partners", - "category": "partner", "pricing": "revenue_share", - "endpoints": []string{"POST /embedded/quote", "POST /embedded/purchase", "GET /embedded/products"}, - "rate_limit": "2000/hour", - }, - }, - }) -} - -func handleSubscribe(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]interface{}{ - "subscription_id": fmt.Sprintf("SUB-%d", time.Now().UnixNano()%1000000), - "api_key": "ngp_test_xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx", - "status": "active", - "sandbox_url": "https://sandbox.ngapp.ng/v1", - "docs_url": "https://docs.ngapp.ng", - }) -} - -func handleUsage(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "period": "2026-05", - "partner_id": "PTR-001", - "apis": []map[string]interface{}{ - {"api": "Quote API", "calls": 15420, "errors": 23, "avg_latency_ms": 85}, - {"api": "Policy API", "calls": 3200, "errors": 5, "avg_latency_ms": 120}, - {"api": "Claims API", "calls": 890, "errors": 2, "avg_latency_ms": 200}, - }, - "total_calls": 19510, - "billing_amount": 45000, - "currency": "NGN", - }) -} - -func handlePartners(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "partners": []map[string]interface{}{ - {"id": "PTR-001", "name": "Kuda Bank", "type": "bank", "status": "active", "apis_subscribed": 4}, - {"id": "PTR-002", "name": "Jumia", "type": "e-commerce", "status": "active", "apis_subscribed": 2}, - {"id": "PTR-003", "name": "Gokada", "type": "ride-hailing", "status": "active", "apis_subscribed": 3}, - {"id": "PTR-004", "name": "PiggyVest", "type": "fintech", "status": "onboarding", "apis_subscribed": 1}, - }, - }) -} - -func handleSandbox(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "sandbox_url": "https://sandbox.ngapp.ng/v1", - "test_credentials": map[string]string{ - "api_key": "ngp_test_sandbox_key", - "partner_id": "PTR-SANDBOX", - }, - "test_data": map[string]string{ - "test_customer_bvn": "12345678901", - "test_vehicle_reg": "LAG-TEST-001", - "test_phone": "+2348000000000", - }, - "features": []string{"Full API access", "No rate limits", "Mock payments", "Test certificates"}, + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"healthy","service":"api-marketplace","version":"2.0.0"}`)) }) + log.Printf("API Marketplace v2.0 starting on port %s", port) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), mux)) } diff --git a/api-marketplace/go.mod b/api-marketplace/go.mod index ce7481b5c..ddae61417 100644 --- a/api-marketplace/go.mod +++ b/api-marketplace/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/api-marketplace +module api-marketplace go 1.22.0 diff --git a/api-marketplace/internal/handlers/handlers.go b/api-marketplace/internal/handlers/handlers.go new file mode 100644 index 000000000..45529e27f --- /dev/null +++ b/api-marketplace/internal/handlers/handlers.go @@ -0,0 +1,50 @@ +package handlers + +import ( + "api-marketplace/internal/service" + "encoding/json" + "net/http" + "strings" +) + +type Handler struct { svc *service.MarketplaceService } +func NewHandler(svc *service.MarketplaceService) *Handler { return &Handler{svc: svc} } + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/marketplace/products", h.GetProducts) + mux.HandleFunc("/api/v1/marketplace/product/", h.GetProduct) + mux.HandleFunc("/api/v1/marketplace/subscribe", h.Subscribe) + mux.HandleFunc("/api/v1/marketplace/subscriptions/", h.GetSubscriptions) + mux.HandleFunc("/api/v1/marketplace/stats", h.GetStats) +} + +func rj(w http.ResponseWriter, s int, d interface{}) { w.Header().Set("Content-Type","application/json"); w.WriteHeader(s); json.NewEncoder(w).Encode(d) } +func re(w http.ResponseWriter, s int, m string) { rj(w, s, map[string]string{"error": m}) } + +func (h *Handler) GetProducts(w http.ResponseWriter, r *http.Request) { + cat := r.URL.Query().Get("category") + rj(w, 200, map[string]interface{}{"products": h.svc.GetProducts(cat)}) +} + +func (h *Handler) GetProduct(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/marketplace/product/") + p, err := h.svc.GetProduct(id) + if err != nil { re(w, 404, err.Error()); return } + rj(w, 200, p) +} + +func (h *Handler) Subscribe(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { re(w, 405, "Method not allowed"); return } + var req service.SubscribeRequest + json.NewDecoder(r.Body).Decode(&req) + sub, err := h.svc.Subscribe(req) + if err != nil { re(w, 400, err.Error()); return } + rj(w, 201, sub) +} + +func (h *Handler) GetSubscriptions(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/marketplace/subscriptions/") + rj(w, 200, map[string]interface{}{"subscriptions": h.svc.GetSubscriptions(id)}) +} + +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { rj(w, 200, h.svc.GetStats()) } diff --git a/api-marketplace/internal/models/marketplace.go b/api-marketplace/internal/models/marketplace.go new file mode 100644 index 000000000..168b592de --- /dev/null +++ b/api-marketplace/internal/models/marketplace.go @@ -0,0 +1,39 @@ +package models + +import "time" + +type APIProduct struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Version string `json:"version"` + Category string `json:"category"` + Provider string `json:"provider"` + BaseURL string `json:"base_url"` + DocsURL string `json:"docs_url"` + Pricing string `json:"pricing"` + RateLimit int `json:"rate_limit_per_min"` + Status string `json:"status"` + Subscribers int `json:"subscribers"` + Endpoints []APIEndpoint `json:"endpoints"` + CreatedAt time.Time `json:"created_at"` +} + +type APIEndpoint struct { + Method string `json:"method"` + Path string `json:"path"` + Description string `json:"description"` +} + +type Subscription struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + ProductID string `json:"product_id"` + APIKey string `json:"api_key"` + Plan string `json:"plan"` + Status string `json:"status"` + CallsUsed int `json:"calls_used"` + CallsLimit int `json:"calls_limit"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/api-marketplace/internal/repository/repository.go b/api-marketplace/internal/repository/repository.go new file mode 100644 index 000000000..2dd9e185a --- /dev/null +++ b/api-marketplace/internal/repository/repository.go @@ -0,0 +1,110 @@ +package repository + +import ( + "api-marketplace/internal/models" + "crypto/rand" + "encoding/hex" + "fmt" + "sync" + "time" +) + +type MarketplaceRepository struct { + mu sync.RWMutex + products map[string]*models.APIProduct + subscriptions map[string]*models.Subscription +} + +func NewMarketplaceRepository() *MarketplaceRepository { + repo := &MarketplaceRepository{ + products: make(map[string]*models.APIProduct), + subscriptions: make(map[string]*models.Subscription), + } + repo.seedProducts() + return repo +} + +func (r *MarketplaceRepository) seedProducts() { + products := []models.APIProduct{ + {ID: "API-001", Name: "Claims Processing API", Description: "End-to-end claims submission, adjudication, and payout", Version: "2.0", Category: "claims", Provider: "NGInsure Core", Pricing: "free_tier", RateLimit: 100, Status: "active", Subscribers: 45, + Endpoints: []models.APIEndpoint{{Method: "POST", Path: "/claims/submit", Description: "Submit a new claim"}, {Method: "GET", Path: "/claims/{id}", Description: "Get claim status"}, {Method: "POST", Path: "/claims/{id}/approve", Description: "Approve a claim"}}, CreatedAt: time.Now().AddDate(-1, 0, 0)}, + {ID: "API-002", Name: "Underwriting API", Description: "Risk assessment and policy pricing", Version: "1.5", Category: "underwriting", Provider: "NGInsure Core", Pricing: "per_call", RateLimit: 50, Status: "active", Subscribers: 32, + Endpoints: []models.APIEndpoint{{Method: "POST", Path: "/underwrite/assess", Description: "Assess risk"}, {Method: "POST", Path: "/underwrite/price", Description: "Calculate premium"}}, CreatedAt: time.Now().AddDate(0, -8, 0)}, + {ID: "API-003", Name: "KYC/KYB Verification", Description: "Identity verification with NIN, BVN, CAC lookup", Version: "2.1", Category: "compliance", Provider: "NGInsure Identity", Pricing: "per_call", RateLimit: 30, Status: "active", Subscribers: 78, + Endpoints: []models.APIEndpoint{{Method: "POST", Path: "/kyc/verify-nin", Description: "Verify NIN"}, {Method: "POST", Path: "/kyc/verify-bvn", Description: "Verify BVN"}, {Method: "POST", Path: "/kyc/liveness", Description: "Liveness check"}}, CreatedAt: time.Now().AddDate(0, -6, 0)}, + {ID: "API-004", Name: "Payment Gateway", Description: "Multi-channel payment processing (bank, mobile money, USSD)", Version: "3.0", Category: "payments", Provider: "NGInsure Payments", Pricing: "per_transaction", RateLimit: 200, Status: "active", Subscribers: 120, + Endpoints: []models.APIEndpoint{{Method: "POST", Path: "/pay/initiate", Description: "Initiate payment"}, {Method: "GET", Path: "/pay/{ref}/status", Description: "Payment status"}, {Method: "POST", Path: "/pay/payout", Description: "Disbursement"}}, CreatedAt: time.Now().AddDate(-1, -3, 0)}, + {ID: "API-005", Name: "Telematics & UBI", Description: "Vehicle telematics data ingestion and driving score", Version: "1.0", Category: "iot", Provider: "NGInsure IoT", Pricing: "per_device", RateLimit: 500, Status: "active", Subscribers: 15, + Endpoints: []models.APIEndpoint{{Method: "POST", Path: "/telemetry/ingest", Description: "Ingest data"}, {Method: "GET", Path: "/telemetry/{policy}/score", Description: "Driving score"}}, CreatedAt: time.Now().AddDate(0, -2, 0)}, + } + for i := range products { + r.products[products[i].ID] = &products[i] + } +} + +func generateAPIKey() string { + b := make([]byte, 24) + rand.Read(b) + return "ngk_" + hex.EncodeToString(b) +} + +func (r *MarketplaceRepository) GetProducts(category string) []models.APIProduct { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.APIProduct + for _, p := range r.products { + if category != "" && p.Category != category { continue } + if p.Status == "active" { result = append(result, *p) } + } + return result +} + +func (r *MarketplaceRepository) GetProduct(id string) (*models.APIProduct, error) { + r.mu.RLock() + defer r.mu.RUnlock() + p, ok := r.products[id] + if !ok { return nil, fmt.Errorf("product %s not found", id) } + return p, nil +} + +func (r *MarketplaceRepository) Subscribe(tenantID, productID, plan string) (*models.Subscription, error) { + r.mu.Lock() + defer r.mu.Unlock() + p, ok := r.products[productID] + if !ok { return nil, fmt.Errorf("product %s not found", productID) } + callsLimit := 1000 + switch plan { + case "professional": callsLimit = 10000 + case "enterprise": callsLimit = 100000 + } + sub := &models.Subscription{ + ID: fmt.Sprintf("SUB-%d", time.Now().UnixNano()%10000000), + TenantID: tenantID, ProductID: productID, + APIKey: generateAPIKey(), Plan: plan, Status: "active", + CallsLimit: callsLimit, ExpiresAt: time.Now().AddDate(0, 1, 0), CreatedAt: time.Now(), + } + r.subscriptions[sub.ID] = sub + p.Subscribers++ + return sub, nil +} + +func (r *MarketplaceRepository) GetSubscriptions(tenantID string) []models.Subscription { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.Subscription + for _, s := range r.subscriptions { + if s.TenantID == tenantID { result = append(result, *s) } + } + return result +} + +func (r *MarketplaceRepository) GetStats() map[string]interface{} { + r.mu.RLock() + defer r.mu.RUnlock() + totalSubs := len(r.subscriptions) + byCat := map[string]int{} + for _, p := range r.products { byCat[p.Category]++ } + return map[string]interface{}{ + "total_products": len(r.products), "total_subscriptions": totalSubs, "by_category": byCat, + } +} diff --git a/api-marketplace/internal/service/service.go b/api-marketplace/internal/service/service.go new file mode 100644 index 000000000..c363d8696 --- /dev/null +++ b/api-marketplace/internal/service/service.go @@ -0,0 +1,30 @@ +package service + +import ( + "api-marketplace/internal/models" + "api-marketplace/internal/repository" + "fmt" +) + +type MarketplaceService struct { repo *repository.MarketplaceRepository } +func NewMarketplaceService(repo *repository.MarketplaceRepository) *MarketplaceService { return &MarketplaceService{repo: repo} } + +func (s *MarketplaceService) GetProducts(category string) []models.APIProduct { return s.repo.GetProducts(category) } +func (s *MarketplaceService) GetProduct(id string) (*models.APIProduct, error) { return s.repo.GetProduct(id) } + +type SubscribeRequest struct { + TenantID string `json:"tenant_id"` + ProductID string `json:"product_id"` + Plan string `json:"plan"` +} + +func (s *MarketplaceService) Subscribe(req SubscribeRequest) (*models.Subscription, error) { + if req.TenantID == "" || req.ProductID == "" { + return nil, fmt.Errorf("tenant_id and product_id are required") + } + if req.Plan == "" { req.Plan = "starter" } + return s.repo.Subscribe(req.TenantID, req.ProductID, req.Plan) +} + +func (s *MarketplaceService) GetSubscriptions(tenantID string) []models.Subscription { return s.repo.GetSubscriptions(tenantID) } +func (s *MarketplaceService) GetStats() map[string]interface{} { return s.repo.GetStats() } diff --git a/blockchain-transparency/cmd/server/main.go b/blockchain-transparency/cmd/server/main.go index 91461abb9..033a8ca05 100644 --- a/blockchain-transparency/cmd/server/main.go +++ b/blockchain-transparency/cmd/server/main.go @@ -1,14 +1,14 @@ package main import ( - "crypto/sha256" - "encoding/hex" - "encoding/json" "fmt" "log" "net/http" "os" - "time" + + "blockchain-transparency/internal/handlers" + "blockchain-transparency/internal/repository" + "blockchain-transparency/internal/service" ) func main() { @@ -16,106 +16,22 @@ func main() { if port == "" { port = "8104" } + + repo := repository.NewBlockchainRepository() + svc := service.NewBlockchainService(repo) + handler := handlers.NewHandler(svc) + mux := http.NewServeMux() - mux.HandleFunc("/api/v1/blockchain/record", handleRecord) - mux.HandleFunc("/api/v1/blockchain/verify", handleVerify) - mux.HandleFunc("/api/v1/blockchain/trail/", handleAuditTrail) - mux.HandleFunc("/api/v1/blockchain/certificate/", handleCertificate) + handler.RegisterRoutes(mux) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"blockchain-transparency"}`)) + w.Write([]byte(`{"status":"healthy","service":"blockchain-transparency","version":"2.0.0"}`)) }) - log.Printf("Blockchain Transparency starting on port %s", port) + + log.Printf("Blockchain Transparency Service v2.0 starting on port %s", port) if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { log.Fatal(err) } } - -type BlockRecord struct { - BlockHash string `json:"block_hash"` - PreviousHash string `json:"previous_hash"` - Timestamp time.Time `json:"timestamp"` - RecordType string `json:"record_type"` - EntityID string `json:"entity_id"` - Action string `json:"action"` - DataHash string `json:"data_hash"` - RecordedBy string `json:"recorded_by"` -} - -func computeHash(data string) string { - hash := sha256.Sum256([]byte(data)) - return hex.EncodeToString(hash[:]) -} - -func handleRecord(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { - RecordType string `json:"record_type"` // claim, policy, payment, payout - EntityID string `json:"entity_id"` - Action string `json:"action"` - Data string `json:"data"` - } - json.NewDecoder(r.Body).Decode(&req) - - dataHash := computeHash(req.Data) - blockData := fmt.Sprintf("%s:%s:%s:%s:%d", req.RecordType, req.EntityID, req.Action, dataHash, time.Now().UnixNano()) - blockHash := computeHash(blockData) - - record := BlockRecord{ - BlockHash: blockHash, - PreviousHash: computeHash("genesis"), - Timestamp: time.Now(), - RecordType: req.RecordType, - EntityID: req.EntityID, - Action: req.Action, - DataHash: dataHash, - RecordedBy: "system", - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]interface{}{ - "record": record, - "message": "Record immutably stored on blockchain", - }) -} - -func handleVerify(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "verified": true, - "integrity": "intact", - "block_count": 1247, - "last_block": computeHash(fmt.Sprintf("block-%d", time.Now().UnixNano())), - }) -} - -func handleAuditTrail(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "entity_id": "CLM-12345", - "trail": []map[string]interface{}{ - {"action": "claim_submitted", "timestamp": "2026-05-10T10:00:00Z", "actor": "customer", "hash": computeHash("submitted")}, - {"action": "documents_uploaded", "timestamp": "2026-05-10T10:05:00Z", "actor": "customer", "hash": computeHash("documents")}, - {"action": "ai_assessment", "timestamp": "2026-05-10T10:05:30Z", "actor": "ai-claims-engine", "hash": computeHash("assessed")}, - {"action": "auto_approved", "timestamp": "2026-05-10T10:06:00Z", "actor": "system", "hash": computeHash("approved")}, - {"action": "payout_initiated", "timestamp": "2026-05-10T10:06:05Z", "actor": "payout-service", "hash": computeHash("payout")}, - {"action": "payout_completed", "timestamp": "2026-05-10T10:06:35Z", "actor": "mobile-money", "hash": computeHash("completed")}, - }, - "verified": true, - }) -} - -func handleCertificate(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "certificate_hash": computeHash(fmt.Sprintf("cert-%d", time.Now().UnixNano())), - "verified": true, - "issuer": "NGApp Insurance Platform", - "issued_at": time.Now().Format(time.RFC3339), - "verification_url": "https://verify.ngapp.ng/cert/", - }) -} diff --git a/blockchain-transparency/go.mod b/blockchain-transparency/go.mod index a2ae10804..6696d77c8 100644 --- a/blockchain-transparency/go.mod +++ b/blockchain-transparency/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/blockchain-transparency +module blockchain-transparency go 1.22.0 diff --git a/blockchain-transparency/internal/handlers/handlers.go b/blockchain-transparency/internal/handlers/handlers.go new file mode 100644 index 000000000..ea9f5639a --- /dev/null +++ b/blockchain-transparency/internal/handlers/handlers.go @@ -0,0 +1,114 @@ +package handlers + +import ( + "blockchain-transparency/internal/service" + "encoding/json" + "net/http" + "strconv" + "strings" +) + +type Handler struct { + svc *service.BlockchainService +} + +func NewHandler(svc *service.BlockchainService) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/blockchain/transaction", h.RecordTransaction) + mux.HandleFunc("/api/v1/blockchain/transaction/", h.GetTransaction) + mux.HandleFunc("/api/v1/blockchain/mine", h.MineBlock) + mux.HandleFunc("/api/v1/blockchain/block/", h.GetBlock) + mux.HandleFunc("/api/v1/blockchain/chain", h.GetChain) + mux.HandleFunc("/api/v1/blockchain/validate", h.ValidateChain) + mux.HandleFunc("/api/v1/blockchain/audit", h.GetAuditLog) + mux.HandleFunc("/api/v1/blockchain/stats", h.GetStats) +} + +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func respondError(w http.ResponseWriter, status int, msg string) { + respondJSON(w, status, map[string]string{"error": msg}) +} + +func (h *Handler) RecordTransaction(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + var req service.RecordTxRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body") + return + } + tx, err := h.svc.RecordTransaction(req) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusCreated, tx) +} + +func (h *Handler) GetTransaction(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/blockchain/transaction/") + tx, err := h.svc.GetTransaction(id) + if err != nil { + respondError(w, http.StatusNotFound, err.Error()) + return + } + respondJSON(w, http.StatusOK, tx) +} + +func (h *Handler) MineBlock(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + block, err := h.svc.MineBlock() + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusCreated, block) +} + +func (h *Handler) GetBlock(w http.ResponseWriter, r *http.Request) { + idStr := strings.TrimPrefix(r.URL.Path, "/api/v1/blockchain/block/") + index, err := strconv.ParseInt(idStr, 10, 64) + if err != nil { + respondError(w, http.StatusBadRequest, "Invalid block index") + return + } + block, err := h.svc.GetBlock(index) + if err != nil { + respondError(w, http.StatusNotFound, err.Error()) + return + } + respondJSON(w, http.StatusOK, block) +} + +func (h *Handler) GetChain(w http.ResponseWriter, r *http.Request) { + chain := h.svc.GetChain() + respondJSON(w, http.StatusOK, map[string]interface{}{"blocks": chain, "length": len(chain)}) +} + +func (h *Handler) ValidateChain(w http.ResponseWriter, r *http.Request) { + valid := h.svc.ValidateChain() + respondJSON(w, http.StatusOK, map[string]interface{}{"valid": valid}) +} + +func (h *Handler) GetAuditLog(w http.ResponseWriter, r *http.Request) { + txID := r.URL.Query().Get("transaction_id") + records := h.svc.GetAuditLog(txID) + respondJSON(w, http.StatusOK, map[string]interface{}{"audit_log": records, "count": len(records)}) +} + +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, h.svc.GetStats()) +} diff --git a/blockchain-transparency/internal/models/blockchain.go b/blockchain-transparency/internal/models/blockchain.go new file mode 100644 index 000000000..3533ee735 --- /dev/null +++ b/blockchain-transparency/internal/models/blockchain.go @@ -0,0 +1,63 @@ +package models + +import "time" + +type TransactionType string + +const ( + TxPremiumPayment TransactionType = "premium_payment" + TxClaimPayout TransactionType = "claim_payout" + TxPolicyCreation TransactionType = "policy_creation" + TxPolicyRenewal TransactionType = "policy_renewal" + TxRefund TransactionType = "refund" + TxCommission TransactionType = "commission" +) + +type Block struct { + Index int64 `json:"index"` + Timestamp time.Time `json:"timestamp"` + Hash string `json:"hash"` + PreviousHash string `json:"previous_hash"` + Nonce int64 `json:"nonce"` + Transactions []Transaction `json:"transactions"` + MerkleRoot string `json:"merkle_root"` +} + +type Transaction struct { + ID string `json:"id"` + Type TransactionType `json:"type"` + PolicyID string `json:"policy_id"` + ClaimID string `json:"claim_id,omitempty"` + FromAddress string `json:"from_address"` + ToAddress string `json:"to_address"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Status string `json:"status"` + BlockHash string `json:"block_hash,omitempty"` + BlockIndex int64 `json:"block_index,omitempty"` + Data string `json:"data,omitempty"` + Signature string `json:"signature"` + CreatedAt time.Time `json:"created_at"` + ConfirmedAt *time.Time `json:"confirmed_at,omitempty"` +} + +type AuditRecord struct { + ID string `json:"id"` + TransactionID string `json:"transaction_id"` + Action string `json:"action"` + Actor string `json:"actor"` + Details string `json:"details"` + IPAddress string `json:"ip_address"` + Timestamp time.Time `json:"timestamp"` + Hash string `json:"hash"` +} + +type ChainStats struct { + TotalBlocks int64 `json:"total_blocks"` + TotalTransactions int64 `json:"total_transactions"` + TotalValue float64 `json:"total_value"` + PendingTx int `json:"pending_transactions"` + ChainValid bool `json:"chain_valid"` + LastBlockHash string `json:"last_block_hash"` + LastBlockTime string `json:"last_block_time"` +} diff --git a/blockchain-transparency/internal/repository/repository.go b/blockchain-transparency/internal/repository/repository.go new file mode 100644 index 000000000..556263700 --- /dev/null +++ b/blockchain-transparency/internal/repository/repository.go @@ -0,0 +1,212 @@ +package repository + +import ( + "blockchain-transparency/internal/models" + "crypto/sha256" + "fmt" + "strings" + "sync" + "time" +) + +type BlockchainRepository struct { + mu sync.RWMutex + chain []models.Block + pendingTx []models.Transaction + auditLog []models.AuditRecord + txIndex map[string]*models.Transaction +} + +func NewBlockchainRepository() *BlockchainRepository { + repo := &BlockchainRepository{ + txIndex: make(map[string]*models.Transaction), + } + repo.createGenesisBlock() + return repo +} + +func (r *BlockchainRepository) createGenesisBlock() { + genesis := models.Block{ + Index: 0, + Timestamp: time.Now(), + Hash: "0000000000000000000000000000000000000000000000000000000000000000", + PreviousHash: "", + Nonce: 0, + MerkleRoot: "genesis", + } + genesis.Hash = r.calculateHash(genesis) + r.chain = append(r.chain, genesis) +} + +func (r *BlockchainRepository) calculateHash(block models.Block) string { + data := fmt.Sprintf("%d%s%s%d%s", block.Index, block.Timestamp.String(), block.PreviousHash, block.Nonce, block.MerkleRoot) + hash := sha256.Sum256([]byte(data)) + return fmt.Sprintf("%x", hash) +} + +func (r *BlockchainRepository) calculateTxHash(tx models.Transaction) string { + data := fmt.Sprintf("%s%s%s%s%f%s", tx.ID, tx.Type, tx.FromAddress, tx.ToAddress, tx.Amount, tx.CreatedAt.String()) + hash := sha256.Sum256([]byte(data)) + return fmt.Sprintf("%x", hash) +} + +func (r *BlockchainRepository) calculateMerkleRoot(txs []models.Transaction) string { + if len(txs) == 0 { + return "empty" + } + var hashes []string + for _, tx := range txs { + hashes = append(hashes, r.calculateTxHash(tx)) + } + for len(hashes) > 1 { + var next []string + for i := 0; i < len(hashes); i += 2 { + if i+1 < len(hashes) { + combined := sha256.Sum256([]byte(hashes[i] + hashes[i+1])) + next = append(next, fmt.Sprintf("%x", combined)) + } else { + next = append(next, hashes[i]) + } + } + hashes = next + } + return hashes[0] +} + +func (r *BlockchainRepository) AddTransaction(tx *models.Transaction) { + r.mu.Lock() + defer r.mu.Unlock() + tx.Signature = r.calculateTxHash(*tx) + tx.Status = "pending" + r.pendingTx = append(r.pendingTx, *tx) + r.txIndex[tx.ID] = tx +} + +func (r *BlockchainRepository) MineBlock() *models.Block { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.pendingTx) == 0 { + return nil + } + lastBlock := r.chain[len(r.chain)-1] + merkle := r.calculateMerkleRoot(r.pendingTx) + + block := models.Block{ + Index: lastBlock.Index + 1, + Timestamp: time.Now(), + PreviousHash: lastBlock.Hash, + Transactions: r.pendingTx, + MerkleRoot: merkle, + } + + for nonce := int64(0); ; nonce++ { + block.Nonce = nonce + hash := r.calculateHash(block) + if strings.HasPrefix(hash, "00") { + block.Hash = hash + break + } + if nonce > 100000 { + block.Hash = r.calculateHash(block) + break + } + } + + now := time.Now() + for i := range block.Transactions { + block.Transactions[i].Status = "confirmed" + block.Transactions[i].BlockHash = block.Hash + block.Transactions[i].BlockIndex = block.Index + block.Transactions[i].ConfirmedAt = &now + if tx, ok := r.txIndex[block.Transactions[i].ID]; ok { + tx.Status = "confirmed" + tx.BlockHash = block.Hash + tx.BlockIndex = block.Index + tx.ConfirmedAt = &now + } + } + + r.chain = append(r.chain, block) + r.pendingTx = nil + return &block +} + +func (r *BlockchainRepository) GetBlock(index int64) (*models.Block, error) { + r.mu.RLock() + defer r.mu.RUnlock() + if index < 0 || int(index) >= len(r.chain) { + return nil, fmt.Errorf("block %d not found", index) + } + return &r.chain[index], nil +} + +func (r *BlockchainRepository) GetTransaction(id string) (*models.Transaction, error) { + r.mu.RLock() + defer r.mu.RUnlock() + tx, ok := r.txIndex[id] + if !ok { + return nil, fmt.Errorf("transaction %s not found", id) + } + return tx, nil +} + +func (r *BlockchainRepository) GetChain() []models.Block { + r.mu.RLock() + defer r.mu.RUnlock() + return r.chain +} + +func (r *BlockchainRepository) ValidateChain() bool { + r.mu.RLock() + defer r.mu.RUnlock() + for i := 1; i < len(r.chain); i++ { + if r.chain[i].PreviousHash != r.chain[i-1].Hash { + return false + } + } + return true +} + +func (r *BlockchainRepository) AddAuditRecord(record *models.AuditRecord) { + r.mu.Lock() + defer r.mu.Unlock() + data := fmt.Sprintf("%s%s%s%s", record.TransactionID, record.Action, record.Actor, record.Timestamp.String()) + hash := sha256.Sum256([]byte(data)) + record.Hash = fmt.Sprintf("%x", hash) + r.auditLog = append(r.auditLog, *record) +} + +func (r *BlockchainRepository) GetAuditLog(txID string) []models.AuditRecord { + r.mu.RLock() + defer r.mu.RUnlock() + var records []models.AuditRecord + for _, rec := range r.auditLog { + if txID == "" || rec.TransactionID == txID { + records = append(records, rec) + } + } + return records +} + +func (r *BlockchainRepository) GetStats() models.ChainStats { + r.mu.RLock() + defer r.mu.RUnlock() + var totalTx int64 + var totalValue float64 + for _, b := range r.chain { + totalTx += int64(len(b.Transactions)) + for _, tx := range b.Transactions { + totalValue += tx.Amount + } + } + lastBlock := r.chain[len(r.chain)-1] + return models.ChainStats{ + TotalBlocks: int64(len(r.chain)), + TotalTransactions: totalTx, + TotalValue: totalValue, + PendingTx: len(r.pendingTx), + ChainValid: r.ValidateChain(), + LastBlockHash: lastBlock.Hash, + LastBlockTime: lastBlock.Timestamp.Format(time.RFC3339), + } +} diff --git a/blockchain-transparency/internal/service/service.go b/blockchain-transparency/internal/service/service.go new file mode 100644 index 000000000..8eb7465f7 --- /dev/null +++ b/blockchain-transparency/internal/service/service.go @@ -0,0 +1,105 @@ +package service + +import ( + "blockchain-transparency/internal/models" + "blockchain-transparency/internal/repository" + "fmt" + "time" +) + +type BlockchainService struct { + repo *repository.BlockchainRepository + autoMineSize int +} + +func NewBlockchainService(repo *repository.BlockchainRepository) *BlockchainService { + return &BlockchainService{repo: repo, autoMineSize: 5} +} + +type RecordTxRequest struct { + Type string `json:"type"` + PolicyID string `json:"policy_id"` + ClaimID string `json:"claim_id,omitempty"` + FromAddress string `json:"from_address"` + ToAddress string `json:"to_address"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Data string `json:"data,omitempty"` +} + +func (s *BlockchainService) RecordTransaction(req RecordTxRequest) (*models.Transaction, error) { + if req.Amount < 0 { + return nil, fmt.Errorf("amount cannot be negative") + } + if req.PolicyID == "" { + return nil, fmt.Errorf("policy_id is required") + } + + txType := models.TransactionType(req.Type) + validTypes := map[models.TransactionType]bool{ + models.TxPremiumPayment: true, models.TxClaimPayout: true, + models.TxPolicyCreation: true, models.TxPolicyRenewal: true, + models.TxRefund: true, models.TxCommission: true, + } + if !validTypes[txType] { + return nil, fmt.Errorf("invalid transaction type: %s", req.Type) + } + + tx := &models.Transaction{ + ID: fmt.Sprintf("TX-%d", time.Now().UnixNano()%100000000), + Type: txType, + PolicyID: req.PolicyID, + ClaimID: req.ClaimID, + FromAddress: req.FromAddress, + ToAddress: req.ToAddress, + Amount: req.Amount, + Currency: req.Currency, + Data: req.Data, + CreatedAt: time.Now(), + } + + s.repo.AddTransaction(tx) + + s.repo.AddAuditRecord(&models.AuditRecord{ + ID: fmt.Sprintf("AUD-%d", time.Now().UnixNano()%100000000), + TransactionID: tx.ID, + Action: "transaction_recorded", + Actor: req.FromAddress, + Details: fmt.Sprintf("%s: %s %.2f for policy %s", req.Type, req.Currency, req.Amount, req.PolicyID), + Timestamp: time.Now(), + }) + + return tx, nil +} + +func (s *BlockchainService) MineBlock() (*models.Block, error) { + block := s.repo.MineBlock() + if block == nil { + return nil, fmt.Errorf("no pending transactions to mine") + } + return block, nil +} + +func (s *BlockchainService) GetBlock(index int64) (*models.Block, error) { + return s.repo.GetBlock(index) +} + +func (s *BlockchainService) GetTransaction(id string) (*models.Transaction, error) { + return s.repo.GetTransaction(id) +} + +func (s *BlockchainService) GetChain() []models.Block { + return s.repo.GetChain() +} + +func (s *BlockchainService) ValidateChain() bool { + return s.repo.ValidateChain() +} + +func (s *BlockchainService) GetAuditLog(txID string) []models.AuditRecord { + return s.repo.GetAuditLog(txID) +} + +func (s *BlockchainService) GetStats() models.ChainStats { + return s.repo.GetStats() +} diff --git a/devops-platform/cmd/server/main.go b/devops-platform/cmd/server/main.go index 0cd6e5947..44656b4e6 100644 --- a/devops-platform/cmd/server/main.go +++ b/devops-platform/cmd/server/main.go @@ -1,140 +1,24 @@ package main import ( - "encoding/json" - "fmt" - "log" - "net/http" - "os" - "time" + "fmt"; "log"; "net/http"; "os" + "devops-platform/internal/handlers" + "devops-platform/internal/repository" + "devops-platform/internal/service" ) func main() { port := os.Getenv("PORT") - if port == "" { - port = "8115" - } + if port == "" { port = "8115" } + repo := repository.NewDevOpsRepository() + svc := service.NewDevOpsService(repo) + h := handlers.NewHandler(svc) mux := http.NewServeMux() - mux.HandleFunc("/api/v1/devops/services", handleServices) - mux.HandleFunc("/api/v1/devops/deployments", handleDeployments) - mux.HandleFunc("/api/v1/devops/alerts", handleAlerts) - mux.HandleFunc("/api/v1/devops/sla-dashboard", handleSLADashboard) - mux.HandleFunc("/api/v1/devops/infrastructure", handleInfrastructure) + h.RegisterRoutes(mux) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"devops-platform"}`)) - }) - log.Printf("DevOps Platform starting on port %s", port) - if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { - log.Fatal(err) - } -} - -func handleServices(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "services": []map[string]interface{}{ - {"name": "ussd-gateway", "language": "Go", "status": "healthy", "instances": 3, "cpu_pct": 15, "memory_mb": 128, "version": "1.0.0"}, - {"name": "whatsapp-bot", "language": "TypeScript", "status": "healthy", "instances": 2, "cpu_pct": 20, "memory_mb": 256, "version": "1.0.0"}, - {"name": "ai-claims-engine", "language": "Python", "status": "healthy", "instances": 3, "cpu_pct": 35, "memory_mb": 512, "version": "1.0.0"}, - {"name": "fraud-detection-neural", "language": "Rust", "status": "healthy", "instances": 2, "cpu_pct": 10, "memory_mb": 64, "version": "1.0.0"}, - {"name": "parametric-insurance-engine", "language": "Rust", "status": "healthy", "instances": 2, "cpu_pct": 8, "memory_mb": 96, "version": "1.0.0"}, - {"name": "mobile-money-service", "language": "Go", "status": "healthy", "instances": 4, "cpu_pct": 25, "memory_mb": 192, "version": "1.0.0"}, - {"name": "performance-gateway", "language": "Rust", "status": "healthy", "instances": 3, "cpu_pct": 12, "memory_mb": 48, "version": "1.0.0"}, - {"name": "multi-tenant-platform", "language": "Go", "status": "healthy", "instances": 2, "cpu_pct": 18, "memory_mb": 256, "version": "1.0.0"}, - }, - "total_services": 42, - "healthy": 42, - "unhealthy": 0, - }) -} - -func handleDeployments(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "recent_deployments": []map[string]interface{}{ - { - "id": "DEP-001", "service": "ai-claims-engine", "version": "1.0.1", - "status": "completed", "strategy": "rolling", - "started_at": time.Now().Add(-2 * time.Hour).Format(time.RFC3339), - "completed_at": time.Now().Add(-110 * time.Minute).Format(time.RFC3339), - "deployed_by": "ci/cd", - }, - { - "id": "DEP-002", "service": "mobile-money-service", "version": "1.0.3", - "status": "completed", "strategy": "blue_green", - "started_at": time.Now().Add(-24 * time.Hour).Format(time.RFC3339), - "completed_at": time.Now().Add(-23 * time.Hour).Format(time.RFC3339), - "deployed_by": "ci/cd", - }, - }, - "deployment_frequency": "12 per week", - "change_failure_rate": "2.1%", - "lead_time_for_changes": "45 minutes", - "mean_time_to_recovery": "8 minutes", - "dora_classification": "Elite", - }) -} - -func handleAlerts(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "active_alerts": []map[string]interface{}{}, - "recent_resolved": []map[string]interface{}{ - { - "id": "ALT-001", "severity": "warning", - "service": "payment-gateway", "metric": "latency_p99", - "message": "P99 latency exceeded 500ms threshold", - "triggered_at": "2026-05-15T14:20:00Z", - "resolved_at": "2026-05-15T14:35:00Z", - "resolution": "auto-scaled from 3 to 5 instances", - }, - }, - "alert_channels": []string{"PagerDuty", "Slack #alerts", "Email ops@ngapp.ng"}, - }) -} - -func handleSLADashboard(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "period": "2026-05", - "sla_targets": map[string]interface{}{ - "availability": map[string]interface{}{"target": "99.95%", "actual": "99.97%", "status": "met"}, - "api_latency_p99": map[string]interface{}{"target": "500ms", "actual": "280ms", "status": "met"}, - "claim_processing": map[string]interface{}{"target": "24h for STP", "actual": "2.4h avg", "status": "met"}, - "payout_speed": map[string]interface{}{"target": "24h", "actual": "35min avg", "status": "met"}, - "sms_delivery": map[string]interface{}{"target": "95%", "actual": "97.2%", "status": "met"}, - }, - "error_budget": map[string]interface{}{ - "monthly_budget_min": 21.6, - "consumed_min": 8.5, - "remaining_pct": 60.6, - }, - }) -} - -func handleInfrastructure(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "kubernetes": map[string]interface{}{ - "cluster": "ngapp-prod-01", - "version": "1.29", - "nodes": 12, - "pods_running": 85, - "cpu_utilization": "42%", - "memory_utilization": "58%", - }, - "databases": []map[string]interface{}{ - {"type": "PostgreSQL", "version": "16", "instances": 3, "storage_gb": 500, "role": "primary OLTP"}, - {"type": "Redis", "version": "7.2", "instances": 6, "memory_gb": 12, "role": "cache + sessions"}, - {"type": "Kafka", "version": "3.6", "brokers": 3, "topics": 45, "role": "event streaming"}, - }, - "monitoring": map[string]interface{}{ - "metrics": "Prometheus + Grafana", - "logs": "Loki", - "traces": "Tempo", - "alerts": "PagerDuty", - }, - "monthly_infra_cost_usd": 8500, + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"healthy","service":"devops-platform","version":"2.0.0"}`)) }) + log.Printf("DevOps Platform v2.0 starting on port %s", port) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), mux)) } diff --git a/devops-platform/go.mod b/devops-platform/go.mod index dc6ec4c19..c89ce1ded 100644 --- a/devops-platform/go.mod +++ b/devops-platform/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/devops-platform +module devops-platform go 1.22.0 diff --git a/devops-platform/internal/handlers/handlers.go b/devops-platform/internal/handlers/handlers.go new file mode 100644 index 000000000..4e900ad2d --- /dev/null +++ b/devops-platform/internal/handlers/handlers.go @@ -0,0 +1,42 @@ +package handlers + +import ( + "devops-platform/internal/service" + "encoding/json" + "net/http" +) + +type Handler struct { svc *service.DevOpsService } +func NewHandler(svc *service.DevOpsService) *Handler { return &Handler{svc: svc} } + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/devops/services", h.GetServices) + mux.HandleFunc("/api/v1/devops/metrics", h.GetMetrics) + mux.HandleFunc("/api/v1/devops/deploy", h.Deploy) + mux.HandleFunc("/api/v1/devops/pipelines", h.GetPipelines) + mux.HandleFunc("/api/v1/devops/deployments", h.GetDeployments) + mux.HandleFunc("/api/v1/devops/stats", h.GetStats) +} + +func rj(w http.ResponseWriter, s int, d interface{}) { w.Header().Set("Content-Type","application/json"); w.WriteHeader(s); json.NewEncoder(w).Encode(d) } +func re(w http.ResponseWriter, s int, m string) { rj(w, s, map[string]string{"error": m}) } + +func (h *Handler) GetServices(w http.ResponseWriter, r *http.Request) { rj(w, 200, map[string]interface{}{"services": h.svc.GetServices()}) } +func (h *Handler) GetMetrics(w http.ResponseWriter, r *http.Request) { rj(w, 200, map[string]interface{}{"metrics": h.svc.GetMetrics()}) } +func (h *Handler) Deploy(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { re(w, 405, "Method not allowed"); return } + var req service.DeployRequest + json.NewDecoder(r.Body).Decode(&req) + d, err := h.svc.Deploy(req) + if err != nil { re(w, 400, err.Error()); return } + rj(w, 201, d) +} +func (h *Handler) GetPipelines(w http.ResponseWriter, r *http.Request) { + svc := r.URL.Query().Get("service") + rj(w, 200, map[string]interface{}{"pipelines": h.svc.GetPipelines(svc)}) +} +func (h *Handler) GetDeployments(w http.ResponseWriter, r *http.Request) { + svc := r.URL.Query().Get("service"); env := r.URL.Query().Get("environment") + rj(w, 200, map[string]interface{}{"deployments": h.svc.GetDeployments(svc, env)}) +} +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { rj(w, 200, h.svc.GetStats()) } diff --git a/devops-platform/internal/models/devops.go b/devops-platform/internal/models/devops.go new file mode 100644 index 000000000..82165c255 --- /dev/null +++ b/devops-platform/internal/models/devops.go @@ -0,0 +1,44 @@ +package models + +import "time" + +type Pipeline struct { + ID string `json:"id"` + Name string `json:"name"` + Service string `json:"service"` + Status string `json:"status"` + Branch string `json:"branch"` + Trigger string `json:"trigger"` + Duration string `json:"duration"` + Stages []Stage `json:"stages"` + CreatedAt time.Time `json:"created_at"` +} + +type Stage struct { + Name string `json:"name"` + Status string `json:"status"` + Duration string `json:"duration"` +} + +type Deployment struct { + ID string `json:"id"` + Service string `json:"service"` + Version string `json:"version"` + Environment string `json:"environment"` + Status string `json:"status"` + Strategy string `json:"strategy"` + Replicas int `json:"replicas"` + Region string `json:"region"` + DeployedAt time.Time `json:"deployed_at"` +} + +type ServiceMetric struct { + Service string `json:"service"` + CPU float64 `json:"cpu_pct"` + Memory float64 `json:"memory_pct"` + RequestRate float64 `json:"requests_per_sec"` + ErrorRate float64 `json:"error_rate_pct"` + Latency_p50 float64 `json:"latency_p50_ms"` + Latency_p99 float64 `json:"latency_p99_ms"` + Uptime float64 `json:"uptime_pct"` +} diff --git a/devops-platform/internal/repository/repository.go b/devops-platform/internal/repository/repository.go new file mode 100644 index 000000000..3b6ccecf4 --- /dev/null +++ b/devops-platform/internal/repository/repository.go @@ -0,0 +1,93 @@ +package repository + +import ( + "devops-platform/internal/models" + "fmt" + "math/rand" + "sync" + "time" +) + +type DevOpsRepository struct { + mu sync.RWMutex + pipelines []models.Pipeline + deployments []models.Deployment + services []string +} + +func NewDevOpsRepository() *DevOpsRepository { + return &DevOpsRepository{ + services: []string{ + "ussd-gateway", "whatsapp-bot", "mobile-money-service", "agent-network-platform", + "ai-claims-engine", "ai-underwriting-engine", "fraud-detection-neural", + "microinsurance-engine", "takaful-module", "usage-based-insurance", + "instant-payout-service", "multi-currency-service", "premium-finance-service", + "notification-service", "multi-language-service", "gamification-service", + "performance-gateway", "customer-portal", "api-marketplace", + }, + } +} + +func (r *DevOpsRepository) GetServices() []string { return r.services } + +func (r *DevOpsRepository) GetMetrics() []models.ServiceMetric { + var metrics []models.ServiceMetric + for _, s := range r.services { + metrics = append(metrics, models.ServiceMetric{ + Service: s, CPU: 10 + rand.Float64()*50, Memory: 20 + rand.Float64()*40, + RequestRate: rand.Float64() * 500, ErrorRate: rand.Float64() * 2, + Latency_p50: 5 + rand.Float64()*50, Latency_p99: 50 + rand.Float64()*200, + Uptime: 99 + rand.Float64(), + }) + } + return metrics +} + +func (r *DevOpsRepository) AddPipeline(p models.Pipeline) { + r.mu.Lock() + defer r.mu.Unlock() + r.pipelines = append(r.pipelines, p) +} + +func (r *DevOpsRepository) GetPipelines(service string) []models.Pipeline { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.Pipeline + for _, p := range r.pipelines { + if service == "" || p.Service == service { result = append(result, p) } + } + return result +} + +func (r *DevOpsRepository) AddDeployment(d models.Deployment) { + r.mu.Lock() + defer r.mu.Unlock() + r.deployments = append(r.deployments, d) +} + +func (r *DevOpsRepository) GetDeployments(service, env string) []models.Deployment { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.Deployment + for _, d := range r.deployments { + if (service == "" || d.Service == service) && (env == "" || d.Environment == env) { + result = append(result, d) + } + } + return result +} + +func (r *DevOpsRepository) GetStats() map[string]interface{} { + r.mu.RLock() + defer r.mu.RUnlock() + return map[string]interface{}{ + "total_services": len(r.services), "total_pipelines": len(r.pipelines), + "total_deployments": len(r.deployments), + "avg_deploy_frequency": "4.2/day", "mttr": "12 min", "change_failure_rate": "3.2%", + } +} + +func init() { + _ = fmt.Sprintf + _ = time.Now +} diff --git a/devops-platform/internal/service/service.go b/devops-platform/internal/service/service.go new file mode 100644 index 000000000..d80168879 --- /dev/null +++ b/devops-platform/internal/service/service.go @@ -0,0 +1,60 @@ +package service + +import ( + "devops-platform/internal/models" + "devops-platform/internal/repository" + "fmt" + "time" +) + +type DevOpsService struct { repo *repository.DevOpsRepository } +func NewDevOpsService(repo *repository.DevOpsRepository) *DevOpsService { return &DevOpsService{repo: repo} } + +type DeployRequest struct { + Service string `json:"service"` + Version string `json:"version"` + Environment string `json:"environment"` + Strategy string `json:"strategy"` + Replicas int `json:"replicas"` + Region string `json:"region"` +} + +func (s *DevOpsService) Deploy(req DeployRequest) (*models.Deployment, error) { + if req.Service == "" || req.Version == "" { + return nil, fmt.Errorf("service and version are required") + } + if req.Environment == "" { req.Environment = "staging" } + if req.Strategy == "" { req.Strategy = "rolling" } + if req.Replicas <= 0 { req.Replicas = 2 } + if req.Region == "" { req.Region = "ng-lagos-1" } + + pipeline := models.Pipeline{ + ID: fmt.Sprintf("PL-%d", time.Now().UnixNano()%10000000), + Name: fmt.Sprintf("Deploy %s %s to %s", req.Service, req.Version, req.Environment), + Service: req.Service, Status: "success", Branch: "main", Trigger: "api", + Duration: "3m 42s", + Stages: []models.Stage{ + {Name: "Build", Status: "success", Duration: "1m 12s"}, + {Name: "Test", Status: "success", Duration: "1m 05s"}, + {Name: "Deploy", Status: "success", Duration: "1m 25s"}, + }, + CreatedAt: time.Now(), + } + s.repo.AddPipeline(pipeline) + + deployment := models.Deployment{ + ID: fmt.Sprintf("DEP-%d", time.Now().UnixNano()%10000000), + Service: req.Service, Version: req.Version, + Environment: req.Environment, Status: "running", + Strategy: req.Strategy, Replicas: req.Replicas, + Region: req.Region, DeployedAt: time.Now(), + } + s.repo.AddDeployment(deployment) + return &deployment, nil +} + +func (s *DevOpsService) GetServices() []string { return s.repo.GetServices() } +func (s *DevOpsService) GetMetrics() []models.ServiceMetric { return s.repo.GetMetrics() } +func (s *DevOpsService) GetPipelines(service string) []models.Pipeline { return s.repo.GetPipelines(service) } +func (s *DevOpsService) GetDeployments(service, env string) []models.Deployment { return s.repo.GetDeployments(service, env) } +func (s *DevOpsService) GetStats() map[string]interface{} { return s.repo.GetStats() } diff --git a/dr-ha-service/cmd/server/main.go b/dr-ha-service/cmd/server/main.go index d44874675..6a1a1c9a1 100644 --- a/dr-ha-service/cmd/server/main.go +++ b/dr-ha-service/cmd/server/main.go @@ -1,113 +1,24 @@ package main import ( - "encoding/json" - "fmt" - "log" - "net/http" - "os" - "time" + "fmt"; "log"; "net/http"; "os" + "dr-ha-service/internal/handlers" + "dr-ha-service/internal/repository" + "dr-ha-service/internal/service" ) func main() { port := os.Getenv("PORT") - if port == "" { - port = "8113" - } + if port == "" { port = "8113" } + repo := repository.NewDRRepository() + svc := service.NewDRService(repo) + h := handlers.NewHandler(svc) mux := http.NewServeMux() - mux.HandleFunc("/api/v1/dr/status", handleDRStatus) - mux.HandleFunc("/api/v1/dr/failover", handleFailover) - mux.HandleFunc("/api/v1/dr/backup-status", handleBackupStatus) - mux.HandleFunc("/api/v1/dr/rpo-rto", handleRPORTO) - mux.HandleFunc("/api/v1/dr/regions", handleRegions) + h.RegisterRoutes(mux) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"dr-ha-service"}`)) - }) - log.Printf("DR/HA Service starting on port %s", port) - if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { - log.Fatal(err) - } -} - -func handleDRStatus(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "overall_status": "healthy", - "primary_region": map[string]interface{}{ - "name": "Lagos (AWS af-south-1)", "status": "active", "uptime_pct": 99.97, - "services_healthy": 42, "services_total": 42, - }, - "secondary_region": map[string]interface{}{ - "name": "Nairobi (GCP africa-south1)", "status": "standby", "replication_lag_ms": 250, - "last_sync": time.Now().Add(-1 * time.Minute).Format(time.RFC3339), - }, - "last_failover_test": "2026-04-15T03:00:00Z", - "last_failover_test_result": "success", - "last_failover_test_duration_sec": 45, - }) -} - -func handleFailover(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "action": "failover_initiated", - "from": "Lagos (af-south-1)", - "to": "Nairobi (africa-south1)", - "estimated_time_sec": 30, - "status": "in_progress", - "steps": []map[string]interface{}{ - {"step": 1, "action": "DNS failover", "status": "completed", "duration_ms": 2000}, - {"step": 2, "action": "Database promotion", "status": "in_progress", "duration_ms": 0}, - {"step": 3, "action": "Service health checks", "status": "pending"}, - {"step": 4, "action": "Traffic routing", "status": "pending"}, - }, - }) -} - -func handleBackupStatus(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "backups": []map[string]interface{}{ - {"type": "database_full", "schedule": "daily 02:00 UTC", "last_backup": "2026-05-16T02:00:00Z", "size_gb": 45, "status": "completed", "retention_days": 30}, - {"type": "database_incremental", "schedule": "hourly", "last_backup": "2026-05-16T15:00:00Z", "size_gb": 2, "status": "completed", "retention_days": 7}, - {"type": "document_store", "schedule": "daily 03:00 UTC", "last_backup": "2026-05-16T03:00:00Z", "size_gb": 120, "status": "completed", "retention_days": 90}, - {"type": "config_snapshots", "schedule": "on_change", "last_backup": "2026-05-15T14:30:00Z", "size_gb": 0.1, "status": "completed", "retention_days": 365}, - }, - "total_backup_size_gb": 167.1, - "monthly_storage_cost_usd": 85, - }) -} - -func handleRPORTO(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "sla": map[string]interface{}{ - "target_uptime": "99.95%", - "actual_uptime": "99.97%", - "target_rpo": "1 hour", - "actual_rpo": "15 minutes", - "target_rto": "4 hours", - "actual_rto": "30 minutes", - }, - "incidents_ytd": []map[string]interface{}{ - {"date": "2026-02-10", "duration_min": 12, "impact": "partial", "root_cause": "Database connection pool exhaustion", "resolved_by": "auto-scaling"}, - {"date": "2026-03-25", "duration_min": 5, "impact": "none", "root_cause": "Network blip af-south-1a", "resolved_by": "AZ failover"}, - }, - }) -} - -func handleRegions(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "regions": []map[string]interface{}{ - {"name": "Lagos", "provider": "AWS", "region": "af-south-1", "role": "primary", "status": "active", "latency_ms": 5}, - {"name": "Nairobi", "provider": "GCP", "region": "africa-south1", "role": "secondary", "status": "standby", "latency_ms": 45}, - {"name": "Johannesburg", "provider": "Azure", "region": "southafricanorth", "role": "disaster_recovery", "status": "cold_standby", "latency_ms": 60}, - }, + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"healthy","service":"dr-ha-service","version":"2.0.0"}`)) }) + log.Printf("DR/HA Service v2.0 starting on port %s", port) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), mux)) } diff --git a/dr-ha-service/go.mod b/dr-ha-service/go.mod index 28358a3d3..c28c71bb5 100644 --- a/dr-ha-service/go.mod +++ b/dr-ha-service/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/dr-ha-service +module dr-ha-service go 1.22.0 diff --git a/dr-ha-service/internal/handlers/handlers.go b/dr-ha-service/internal/handlers/handlers.go new file mode 100644 index 000000000..1d4f222f9 --- /dev/null +++ b/dr-ha-service/internal/handlers/handlers.go @@ -0,0 +1,56 @@ +package handlers + +import ( + "dr-ha-service/internal/service" + "encoding/json" + "net/http" + "strings" +) + +type Handler struct { svc *service.DRService } +func NewHandler(svc *service.DRService) *Handler { return &Handler{svc: svc} } + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/dr/nodes", h.GetNodes) + mux.HandleFunc("/api/v1/dr/node/", h.GetNode) + mux.HandleFunc("/api/v1/dr/failover", h.TriggerFailover) + mux.HandleFunc("/api/v1/dr/failovers", h.GetFailovers) + mux.HandleFunc("/api/v1/dr/backup", h.CreateBackup) + mux.HandleFunc("/api/v1/dr/backups", h.GetBackups) + mux.HandleFunc("/api/v1/dr/plans", h.GetPlans) + mux.HandleFunc("/api/v1/dr/stats", h.GetStats) +} + +func rj(w http.ResponseWriter, s int, d interface{}) { w.Header().Set("Content-Type","application/json"); w.WriteHeader(s); json.NewEncoder(w).Encode(d) } +func re(w http.ResponseWriter, s int, m string) { rj(w, s, map[string]string{"error": m}) } + +func (h *Handler) GetNodes(w http.ResponseWriter, r *http.Request) { rj(w, 200, map[string]interface{}{"nodes": h.svc.GetNodes()}) } +func (h *Handler) GetNode(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/dr/node/") + n, err := h.svc.GetNode(id) + if err != nil { re(w, 404, err.Error()); return } + rj(w, 200, n) +} +func (h *Handler) TriggerFailover(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { re(w, 405, "Method not allowed"); return } + var req struct { SourceID string `json:"source_id"`; TargetID string `json:"target_id"`; Reason string `json:"reason"` } + json.NewDecoder(r.Body).Decode(&req) + f, err := h.svc.TriggerFailover(req.SourceID, req.TargetID, req.Reason) + if err != nil { re(w, 400, err.Error()); return } + rj(w, 200, f) +} +func (h *Handler) GetFailovers(w http.ResponseWriter, r *http.Request) { rj(w, 200, map[string]interface{}{"failovers": h.svc.GetFailovers()}) } +func (h *Handler) CreateBackup(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { re(w, 405, "Method not allowed"); return } + var req struct { NodeID string `json:"node_id"`; Type string `json:"type"` } + json.NewDecoder(r.Body).Decode(&req) + b, err := h.svc.CreateBackup(req.NodeID, req.Type) + if err != nil { re(w, 400, err.Error()); return } + rj(w, 201, b) +} +func (h *Handler) GetBackups(w http.ResponseWriter, r *http.Request) { + nid := r.URL.Query().Get("node_id") + rj(w, 200, map[string]interface{}{"backups": h.svc.GetBackups(nid)}) +} +func (h *Handler) GetPlans(w http.ResponseWriter, r *http.Request) { rj(w, 200, map[string]interface{}{"plans": h.svc.GetPlans()}) } +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { rj(w, 200, h.svc.GetStats()) } diff --git a/dr-ha-service/internal/models/dr.go b/dr-ha-service/internal/models/dr.go new file mode 100644 index 000000000..9de33f7cb --- /dev/null +++ b/dr-ha-service/internal/models/dr.go @@ -0,0 +1,54 @@ +package models + +import "time" + +type ServiceNode struct { + ID string `json:"id"` + Name string `json:"name"` + Region string `json:"region"` + Type string `json:"type"` + Status string `json:"status"` + Health float64 `json:"health_pct"` + CPU float64 `json:"cpu_pct"` + Memory float64 `json:"memory_pct"` + Uptime string `json:"uptime"` + LastCheck time.Time `json:"last_check"` +} + +type FailoverEvent struct { + ID string `json:"id"` + SourceNode string `json:"source_node"` + TargetNode string `json:"target_node"` + Reason string `json:"reason"` + Status string `json:"status"` + Duration string `json:"duration"` + DataLoss bool `json:"data_loss"` + RPOAchieved string `json:"rpo_achieved"` + RTOAchieved string `json:"rto_achieved"` + InitiatedAt time.Time `json:"initiated_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +type BackupRecord struct { + ID string `json:"id"` + NodeID string `json:"node_id"` + Type string `json:"type"` + SizeMB int `json:"size_mb"` + Status string `json:"status"` + Encrypted bool `json:"encrypted"` + Checksum string `json:"checksum"` + Location string `json:"location"` + RetainUntil time.Time `json:"retain_until"` + CreatedAt time.Time `json:"created_at"` +} + +type DRPlan struct { + ID string `json:"id"` + Name string `json:"name"` + RPOTarget string `json:"rpo_target"` + RTOTarget string `json:"rto_target"` + Strategy string `json:"strategy"` + Nodes []string `json:"nodes"` + LastTested *time.Time `json:"last_tested,omitempty"` + Status string `json:"status"` +} diff --git a/dr-ha-service/internal/repository/repository.go b/dr-ha-service/internal/repository/repository.go new file mode 100644 index 000000000..2712bcafc --- /dev/null +++ b/dr-ha-service/internal/repository/repository.go @@ -0,0 +1,119 @@ +package repository + +import ( + "dr-ha-service/internal/models" + "fmt" + "math/rand" + "sync" + "time" +) + +type DRRepository struct { + mu sync.RWMutex + nodes map[string]*models.ServiceNode + failovers []models.FailoverEvent + backups []models.BackupRecord + plans map[string]*models.DRPlan +} + +func NewDRRepository() *DRRepository { + repo := &DRRepository{ + nodes: make(map[string]*models.ServiceNode), + plans: make(map[string]*models.DRPlan), + } + repo.seedNodes() + repo.seedPlans() + return repo +} + +func (r *DRRepository) seedNodes() { + nodes := []models.ServiceNode{ + {ID: "NODE-001", Name: "Primary Lagos", Region: "ng-lagos-1", Type: "primary", Status: "active", Health: 99.5, CPU: 45, Memory: 62, Uptime: "45d 12h 30m", LastCheck: time.Now()}, + {ID: "NODE-002", Name: "Secondary Abuja", Region: "ng-abuja-1", Type: "secondary", Status: "standby", Health: 100, CPU: 12, Memory: 35, Uptime: "45d 12h 30m", LastCheck: time.Now()}, + {ID: "NODE-003", Name: "DR Nairobi", Region: "ke-nairobi-1", Type: "disaster_recovery", Status: "standby", Health: 100, CPU: 8, Memory: 28, Uptime: "30d 6h 15m", LastCheck: time.Now()}, + {ID: "NODE-004", Name: "Edge Kano", Region: "ng-kano-1", Type: "edge", Status: "active", Health: 98.2, CPU: 55, Memory: 48, Uptime: "15d 3h 45m", LastCheck: time.Now()}, + {ID: "NODE-005", Name: "Edge Accra", Region: "gh-accra-1", Type: "edge", Status: "active", Health: 97.8, CPU: 38, Memory: 42, Uptime: "22d 8h 10m", LastCheck: time.Now()}, + } + for i := range nodes { + r.nodes[nodes[i].ID] = &nodes[i] + } +} + +func (r *DRRepository) seedPlans() { + tested := time.Now().AddDate(0, -1, 0) + plans := []models.DRPlan{ + {ID: "DRP-001", Name: "Active-Passive Failover", RPOTarget: "5 minutes", RTOTarget: "15 minutes", Strategy: "active_passive", Nodes: []string{"NODE-001", "NODE-002"}, LastTested: &tested, Status: "active"}, + {ID: "DRP-002", Name: "Cross-Region DR", RPOTarget: "1 hour", RTOTarget: "4 hours", Strategy: "warm_standby", Nodes: []string{"NODE-001", "NODE-003"}, LastTested: &tested, Status: "active"}, + {ID: "DRP-003", Name: "Edge Failover", RPOTarget: "15 minutes", RTOTarget: "30 minutes", Strategy: "active_active", Nodes: []string{"NODE-004", "NODE-005"}, Status: "active"}, + } + for i := range plans { + r.plans[plans[i].ID] = &plans[i] + } +} + +func (r *DRRepository) GetNodes() []models.ServiceNode { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.ServiceNode + for _, n := range r.nodes { + n.CPU = 10 + rand.Float64()*60 + n.Memory = 20 + rand.Float64()*50 + n.LastCheck = time.Now() + result = append(result, *n) + } + return result +} + +func (r *DRRepository) GetNode(id string) (*models.ServiceNode, error) { + r.mu.RLock() + defer r.mu.RUnlock() + n, ok := r.nodes[id] + if !ok { return nil, fmt.Errorf("node %s not found", id) } + return n, nil +} + +func (r *DRRepository) AddFailover(f models.FailoverEvent) { + r.mu.Lock() + defer r.mu.Unlock() + r.failovers = append(r.failovers, f) +} + +func (r *DRRepository) GetFailovers() []models.FailoverEvent { + r.mu.RLock() + defer r.mu.RUnlock() + return r.failovers +} + +func (r *DRRepository) AddBackup(b models.BackupRecord) { + r.mu.Lock() + defer r.mu.Unlock() + r.backups = append(r.backups, b) +} + +func (r *DRRepository) GetBackups(nodeID string) []models.BackupRecord { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.BackupRecord + for _, b := range r.backups { + if nodeID == "" || b.NodeID == nodeID { result = append(result, b) } + } + return result +} + +func (r *DRRepository) GetPlans() []models.DRPlan { + var result []models.DRPlan + for _, p := range r.plans { result = append(result, *p) } + return result +} + +func (r *DRRepository) GetStats() map[string]interface{} { + r.mu.RLock() + defer r.mu.RUnlock() + active := 0 + for _, n := range r.nodes { if n.Status == "active" { active++ } } + return map[string]interface{}{ + "total_nodes": len(r.nodes), "active_nodes": active, "total_failovers": len(r.failovers), + "total_backups": len(r.backups), "dr_plans": len(r.plans), + "overall_health": 99.1, "data_replication_lag": "2.3s", + } +} diff --git a/dr-ha-service/internal/service/service.go b/dr-ha-service/internal/service/service.go new file mode 100644 index 000000000..efe6352e8 --- /dev/null +++ b/dr-ha-service/internal/service/service.go @@ -0,0 +1,61 @@ +package service + +import ( + "crypto/sha256" + "dr-ha-service/internal/models" + "dr-ha-service/internal/repository" + "fmt" + "math/rand" + "time" +) + +type DRService struct { repo *repository.DRRepository } +func NewDRService(repo *repository.DRRepository) *DRService { return &DRService{repo: repo} } + +func (s *DRService) TriggerFailover(sourceID, targetID, reason string) (*models.FailoverEvent, error) { + src, err := s.repo.GetNode(sourceID) + if err != nil { return nil, fmt.Errorf("source node: %w", err) } + tgt, err := s.repo.GetNode(targetID) + if err != nil { return nil, fmt.Errorf("target node: %w", err) } + if tgt.Status != "standby" && tgt.Status != "active" { + return nil, fmt.Errorf("target node %s is not ready (status: %s)", targetID, tgt.Status) + } + + now := time.Now() + completed := now.Add(time.Duration(30+rand.Intn(120)) * time.Second) + + event := models.FailoverEvent{ + ID: fmt.Sprintf("FO-%d", time.Now().UnixNano()%10000000), + SourceNode: src.Name, TargetNode: tgt.Name, Reason: reason, + Status: "completed", Duration: completed.Sub(now).String(), + DataLoss: false, RPOAchieved: "3 minutes", RTOAchieved: "12 minutes", + InitiatedAt: now, CompletedAt: &completed, + } + s.repo.AddFailover(event) + return &event, nil +} + +func (s *DRService) CreateBackup(nodeID, backupType string) (*models.BackupRecord, error) { + _, err := s.repo.GetNode(nodeID) + if err != nil { return nil, err } + + hash := sha256.Sum256([]byte(fmt.Sprintf("%s-%s-%d", nodeID, backupType, time.Now().UnixNano()))) + + backup := models.BackupRecord{ + ID: fmt.Sprintf("BKP-%d", time.Now().UnixNano()%10000000), + NodeID: nodeID, Type: backupType, + SizeMB: 500 + rand.Intn(5000), Status: "completed", Encrypted: true, + Checksum: fmt.Sprintf("%x", hash[:16]), + Location: fmt.Sprintf("s3://nginsure-backups/%s/%s", nodeID, time.Now().Format("2006/01/02")), + RetainUntil: time.Now().AddDate(0, 3, 0), CreatedAt: time.Now(), + } + s.repo.AddBackup(backup) + return &backup, nil +} + +func (s *DRService) GetNodes() []models.ServiceNode { return s.repo.GetNodes() } +func (s *DRService) GetNode(id string) (*models.ServiceNode, error) { return s.repo.GetNode(id) } +func (s *DRService) GetFailovers() []models.FailoverEvent { return s.repo.GetFailovers() } +func (s *DRService) GetBackups(nodeID string) []models.BackupRecord { return s.repo.GetBackups(nodeID) } +func (s *DRService) GetPlans() []models.DRPlan { return s.repo.GetPlans() } +func (s *DRService) GetStats() map[string]interface{} { return s.repo.GetStats() } diff --git a/gamification-service/cmd/server/main.go b/gamification-service/cmd/server/main.go index 34d6ed4dc..2f7609b8b 100644 --- a/gamification-service/cmd/server/main.go +++ b/gamification-service/cmd/server/main.go @@ -1,12 +1,14 @@ package main import ( - "encoding/json" "fmt" "log" "net/http" "os" - "time" + + "gamification-service/internal/handlers" + "gamification-service/internal/repository" + "gamification-service/internal/service" ) func main() { @@ -14,111 +16,22 @@ func main() { if port == "" { port = "8110" } + + repo := repository.NewGamificationRepository() + svc := service.NewGamificationService(repo) + handler := handlers.NewHandler(svc) + mux := http.NewServeMux() - mux.HandleFunc("/api/v1/loyalty/profile", handleProfile) - mux.HandleFunc("/api/v1/loyalty/earn", handleEarn) - mux.HandleFunc("/api/v1/loyalty/redeem", handleRedeem) - mux.HandleFunc("/api/v1/loyalty/challenges", handleChallenges) - mux.HandleFunc("/api/v1/loyalty/leaderboard", handleLeaderboard) - mux.HandleFunc("/api/v1/loyalty/tiers", handleTiers) + handler.RegisterRoutes(mux) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"gamification-service"}`)) + w.Write([]byte(`{"status":"healthy","service":"gamification-service","version":"2.0.0"}`)) }) - log.Printf("Gamification Service starting on port %s", port) + + log.Printf("Gamification Service v2.0 starting on port %s", port) if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { log.Fatal(err) } } - -func handleProfile(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "customer_id": "CUST-001", - "points": 2450, - "tier": "Silver", - "tier_progress": map[string]interface{}{ - "current": 2450, "next_tier": "Gold", "required": 5000, "progress_pct": 49, - }, - "lifetime_points": 8200, - "redeemed_points": 5750, - "streak_days": 15, - "badges": []map[string]interface{}{ - {"id": "early_bird", "name": "Early Bird", "description": "Paid premium before due date 3 times", "earned_at": "2026-03-15"}, - {"id": "safe_driver", "name": "Safe Driver", "description": "No claims for 12 months", "earned_at": "2026-01-15"}, - {"id": "referral_star", "name": "Referral Star", "description": "Referred 5 friends", "earned_at": "2026-04-20"}, - }, - "referral_code": "JOHN2450", - "referral_count": 5, - "referral_earnings": 7500, - }) -} - -func handleEarn(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]interface{}{ - "points_earned": 100, - "new_balance": 2550, - "reason": "premium_payment", - "message": "You earned 100 points for paying your premium on time!", - }) -} - -func handleRedeem(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "rewards": []map[string]interface{}{ - {"id": "RWD-001", "name": "Premium Discount 5%", "points_required": 1000, "type": "discount"}, - {"id": "RWD-002", "name": "Free Device Insurance (1 month)", "points_required": 500, "type": "free_cover"}, - {"id": "RWD-003", "name": "N500 Airtime", "points_required": 250, "type": "airtime"}, - {"id": "RWD-004", "name": "N1000 Data Bundle", "points_required": 400, "type": "data"}, - {"id": "RWD-005", "name": "Movie Ticket", "points_required": 750, "type": "entertainment"}, - }, - }) -} - -func handleChallenges(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "active_challenges": []map[string]interface{}{ - {"id": "CH-001", "name": "Pay On Time", "description": "Pay 3 premiums before due date", "reward_points": 500, "progress": 2, "target": 3, "expires": "2026-06-30"}, - {"id": "CH-002", "name": "Refer a Friend", "description": "Get 1 friend to buy a policy", "reward_points": 300, "progress": 0, "target": 1, "expires": "2026-07-31"}, - {"id": "CH-003", "name": "Complete Profile", "description": "Add emergency contact and next of kin", "reward_points": 200, "progress": 1, "target": 2, "expires": "2026-12-31"}, - {"id": "CH-004", "name": "Health Hero", "description": "Log 10,000 steps for 7 days", "reward_points": 150, "progress": 4, "target": 7, "expires": "2026-05-31"}, - }, - }) -} - -func handleLeaderboard(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "period": "2026-05", - "leaderboard": []map[string]interface{}{ - {"rank": 1, "name": "Amina B.", "points": 5200, "tier": "Gold"}, - {"rank": 2, "name": "Chukwu E.", "points": 4800, "tier": "Gold"}, - {"rank": 3, "name": "Adebayo O.", "points": 4500, "tier": "Silver"}, - {"rank": 4, "name": "John O.", "points": 2450, "tier": "Silver", "is_current_user": true}, - }, - }) -} - -func handleTiers(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "tiers": []map[string]interface{}{ - {"name": "Bronze", "min_points": 0, "benefits": []string{"Basic rewards", "SMS notifications"}}, - {"name": "Silver", "min_points": 2000, "benefits": []string{"5% premium discount", "Priority claims", "WhatsApp support"}}, - {"name": "Gold", "min_points": 5000, "benefits": []string{"10% premium discount", "Fast-track claims", "Dedicated agent", "Free device cover"}}, - {"name": "Platinum", "min_points": 10000, "benefits": []string{"15% premium discount", "VIP claims", "Concierge service", "Free family cover add-on"}}, - }, - }) -} diff --git a/gamification-service/go.mod b/gamification-service/go.mod index 967dad15c..ee0f218c9 100644 --- a/gamification-service/go.mod +++ b/gamification-service/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/gamification-service +module gamification-service go 1.22.0 diff --git a/gamification-service/internal/handlers/handlers.go b/gamification-service/internal/handlers/handlers.go new file mode 100644 index 000000000..94ace32d8 --- /dev/null +++ b/gamification-service/internal/handlers/handlers.go @@ -0,0 +1,108 @@ +package handlers + +import ( + "encoding/json" + "gamification-service/internal/service" + "net/http" + "strconv" + "strings" +) + +type Handler struct { + svc *service.GamificationService +} + +func NewHandler(svc *service.GamificationService) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/loyalty/profile/", h.GetProfile) + mux.HandleFunc("/api/v1/loyalty/earn", h.Earn) + mux.HandleFunc("/api/v1/loyalty/redeem", h.Redeem) + mux.HandleFunc("/api/v1/loyalty/rewards", h.GetRewards) + mux.HandleFunc("/api/v1/loyalty/challenges", h.GetChallenges) + mux.HandleFunc("/api/v1/loyalty/leaderboard", h.GetLeaderboard) + mux.HandleFunc("/api/v1/loyalty/tiers", h.GetTiers) + mux.HandleFunc("/api/v1/loyalty/badges", h.GetBadges) + mux.HandleFunc("/api/v1/loyalty/badges/", h.GetEarnedBadges) + mux.HandleFunc("/api/v1/loyalty/history/", h.GetHistory) +} + +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func respondError(w http.ResponseWriter, status int, msg string) { + respondJSON(w, status, map[string]string{"error": msg}) +} + +func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/loyalty/profile/") + p, err := h.svc.GetProfile(id) + if err != nil { respondError(w, http.StatusNotFound, err.Error()); return } + respondJSON(w, http.StatusOK, p) +} + +func (h *Handler) Earn(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { respondError(w, http.StatusMethodNotAllowed, "Method not allowed"); return } + var req service.EarnRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "Invalid request"); return } + profile, pts, err := h.svc.EarnPoints(req) + if err != nil { respondError(w, http.StatusBadRequest, err.Error()); return } + respondJSON(w, http.StatusCreated, map[string]interface{}{ + "points_earned": pts, "new_balance": profile.Points, "tier": profile.Tier, "message": "Points earned successfully!", + }) +} + +func (h *Handler) Redeem(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { respondError(w, http.StatusMethodNotAllowed, "Method not allowed"); return } + var req service.RedeemRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "Invalid request"); return } + red, err := h.svc.RedeemReward(req) + if err != nil { respondError(w, http.StatusBadRequest, err.Error()); return } + respondJSON(w, http.StatusOK, red) +} + +func (h *Handler) GetRewards(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, map[string]interface{}{"rewards": h.svc.GetRewards()}) +} + +func (h *Handler) GetChallenges(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, map[string]interface{}{"active_challenges": h.svc.GetChallenges()}) +} + +func (h *Handler) GetLeaderboard(w http.ResponseWriter, r *http.Request) { + limit := 10 + if l := r.URL.Query().Get("limit"); l != "" { + if v, err := strconv.Atoi(l); err == nil { limit = v } + } + leaders := h.svc.GetLeaderboard(limit) + var board []map[string]interface{} + for i, p := range leaders { + board = append(board, map[string]interface{}{ + "rank": i + 1, "name": p.Name, "points": p.Points, "tier": p.Tier, + }) + } + respondJSON(w, http.StatusOK, map[string]interface{}{"period": "current", "leaderboard": board}) +} + +func (h *Handler) GetTiers(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, map[string]interface{}{"tiers": h.svc.GetTiers()}) +} + +func (h *Handler) GetBadges(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, map[string]interface{}{"badges": h.svc.GetBadges()}) +} + +func (h *Handler) GetEarnedBadges(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/loyalty/badges/") + respondJSON(w, http.StatusOK, map[string]interface{}{"earned_badges": h.svc.GetEarnedBadges(id)}) +} + +func (h *Handler) GetHistory(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/loyalty/history/") + respondJSON(w, http.StatusOK, map[string]interface{}{"history": h.svc.GetPointsHistory(id)}) +} diff --git a/gamification-service/internal/models/gamification.go b/gamification-service/internal/models/gamification.go new file mode 100644 index 000000000..51ec93ad9 --- /dev/null +++ b/gamification-service/internal/models/gamification.go @@ -0,0 +1,97 @@ +package models + +import "time" + +type TierLevel string + +const ( + TierBronze TierLevel = "Bronze" + TierSilver TierLevel = "Silver" + TierGold TierLevel = "Gold" + TierPlatinum TierLevel = "Platinum" +) + +type Profile struct { + ID string `json:"id"` + CustomerID string `json:"customer_id"` + Name string `json:"name"` + Points int `json:"points"` + LifetimePoints int `json:"lifetime_points"` + RedeemedPoints int `json:"redeemed_points"` + Tier TierLevel `json:"tier"` + StreakDays int `json:"streak_days"` + ReferralCode string `json:"referral_code"` + ReferralCount int `json:"referral_count"` + LastActivityAt time.Time `json:"last_activity_at"` + CreatedAt time.Time `json:"created_at"` +} + +type PointsTransaction struct { + ID string `json:"id"` + ProfileID string `json:"profile_id"` + Points int `json:"points"` + Action string `json:"action"` + Reason string `json:"reason"` + Reference string `json:"reference,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type Badge struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + Category string `json:"category"` + Criteria string `json:"criteria"` + PointsValue int `json:"points_value"` +} + +type EarnedBadge struct { + ProfileID string `json:"profile_id"` + BadgeID string `json:"badge_id"` + Badge Badge `json:"badge"` + EarnedAt time.Time `json:"earned_at"` +} + +type Challenge struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + RewardPts int `json:"reward_points"` + Target int `json:"target"` + Category string `json:"category"` + ExpiresAt time.Time `json:"expires_at"` + IsActive bool `json:"is_active"` +} + +type ChallengeProgress struct { + ProfileID string `json:"profile_id"` + ChallengeID string `json:"challenge_id"` + Progress int `json:"progress"` + Completed bool `json:"completed"` +} + +type Reward struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + PointsCost int `json:"points_cost"` + Category string `json:"category"` + IsAvailable bool `json:"is_available"` + Quantity int `json:"quantity"` +} + +type Redemption struct { + ID string `json:"id"` + ProfileID string `json:"profile_id"` + RewardID string `json:"reward_id"` + Points int `json:"points_spent"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +type TierConfig struct { + Tier TierLevel `json:"tier"` + MinPoints int `json:"min_points"` + Benefits []string `json:"benefits"` +} diff --git a/gamification-service/internal/repository/repository.go b/gamification-service/internal/repository/repository.go new file mode 100644 index 000000000..baa83d587 --- /dev/null +++ b/gamification-service/internal/repository/repository.go @@ -0,0 +1,217 @@ +package repository + +import ( + "fmt" + "gamification-service/internal/models" + "strings" + "sync" + "time" +) + +type GamificationRepository struct { + mu sync.RWMutex + profiles map[string]*models.Profile + pointsTx []models.PointsTransaction + badges map[string]models.Badge + earnedBadges map[string][]models.EarnedBadge + challenges map[string]models.Challenge + progress map[string]map[string]*models.ChallengeProgress + rewards map[string]models.Reward + redemptions []models.Redemption + tiers []models.TierConfig +} + +func NewGamificationRepository() *GamificationRepository { + repo := &GamificationRepository{ + profiles: make(map[string]*models.Profile), + badges: make(map[string]models.Badge), + earnedBadges: make(map[string][]models.EarnedBadge), + challenges: make(map[string]models.Challenge), + progress: make(map[string]map[string]*models.ChallengeProgress), + rewards: make(map[string]models.Reward), + tiers: []models.TierConfig{ + {Tier: models.TierBronze, MinPoints: 0, Benefits: []string{"Basic rewards", "SMS notifications"}}, + {Tier: models.TierSilver, MinPoints: 2000, Benefits: []string{"5% premium discount", "Priority claims", "WhatsApp support"}}, + {Tier: models.TierGold, MinPoints: 5000, Benefits: []string{"10% premium discount", "Fast-track claims", "Dedicated agent", "Free device cover"}}, + {Tier: models.TierPlatinum, MinPoints: 10000, Benefits: []string{"15% premium discount", "VIP claims", "Concierge service", "Free family cover add-on"}}, + }, + } + repo.seedBadges() + repo.seedChallenges() + repo.seedRewards() + return repo +} + +func (r *GamificationRepository) seedBadges() { + badges := []models.Badge{ + {ID: "B-001", Name: "Early Bird", Description: "Paid premium before due date 3 times", Icon: "bird", Category: "payment", Criteria: "on_time_payments >= 3", PointsValue: 200}, + {ID: "B-002", Name: "Safe Driver", Description: "No claims for 12 months", Icon: "shield", Category: "safety", Criteria: "claim_free_months >= 12", PointsValue: 500}, + {ID: "B-003", Name: "Referral Star", Description: "Referred 5 friends", Icon: "star", Category: "referral", Criteria: "referrals >= 5", PointsValue: 300}, + {ID: "B-004", Name: "Profile Complete", Description: "Completed all profile fields", Icon: "user-check", Category: "engagement", Criteria: "profile_complete = true", PointsValue: 100}, + {ID: "B-005", Name: "Claim Champion", Description: "Submitted documentation within 24 hours", Icon: "trophy", Category: "claims", Criteria: "fast_claim_docs = true", PointsValue: 150}, + {ID: "B-006", Name: "Loyalty Legend", Description: "Active customer for 2+ years", Icon: "crown", Category: "loyalty", Criteria: "tenure_months >= 24", PointsValue: 1000}, + } + for _, b := range badges { + r.badges[b.ID] = b + } +} + +func (r *GamificationRepository) seedChallenges() { + challenges := []models.Challenge{ + {ID: "CH-001", Name: "Pay On Time", Description: "Pay 3 premiums before due date", RewardPts: 500, Target: 3, Category: "payment", ExpiresAt: time.Now().AddDate(0, 3, 0), IsActive: true}, + {ID: "CH-002", Name: "Refer a Friend", Description: "Get 1 friend to buy a policy", RewardPts: 300, Target: 1, Category: "referral", ExpiresAt: time.Now().AddDate(0, 2, 0), IsActive: true}, + {ID: "CH-003", Name: "Complete Profile", Description: "Add emergency contact and next of kin", RewardPts: 200, Target: 2, Category: "engagement", ExpiresAt: time.Now().AddDate(0, 6, 0), IsActive: true}, + {ID: "CH-004", Name: "Health Hero", Description: "Log 10,000 steps for 7 days", RewardPts: 150, Target: 7, Category: "wellness", ExpiresAt: time.Now().AddDate(0, 1, 0), IsActive: true}, + {ID: "CH-005", Name: "Document Upload", Description: "Upload all required KYC documents", RewardPts: 250, Target: 4, Category: "compliance", ExpiresAt: time.Now().AddDate(0, 1, 0), IsActive: true}, + } + for _, c := range challenges { + r.challenges[c.ID] = c + } +} + +func (r *GamificationRepository) seedRewards() { + rewards := []models.Reward{ + {ID: "RWD-001", Name: "Premium Discount 5%", Description: "5% off next premium", PointsCost: 1000, Category: "discount", IsAvailable: true, Quantity: -1}, + {ID: "RWD-002", Name: "Free Device Insurance (1 month)", Description: "Free phone cover", PointsCost: 500, Category: "free_cover", IsAvailable: true, Quantity: 100}, + {ID: "RWD-003", Name: "₦500 Airtime", Description: "MTN/Glo/Airtel/9mobile", PointsCost: 250, Category: "airtime", IsAvailable: true, Quantity: 500}, + {ID: "RWD-004", Name: "₦1000 Data Bundle", Description: "1GB data bundle", PointsCost: 400, Category: "data", IsAvailable: true, Quantity: 200}, + {ID: "RWD-005", Name: "Movie Ticket", Description: "Filmhouse/Genesis cinema", PointsCost: 750, Category: "entertainment", IsAvailable: true, Quantity: 50}, + } + for _, rw := range rewards { + r.rewards[rw.ID] = rw + } +} + +func (r *GamificationRepository) GetOrCreateProfile(customerID, name string) *models.Profile { + r.mu.Lock() + defer r.mu.Unlock() + if p, ok := r.profiles[customerID]; ok { + return p + } + p := &models.Profile{ + ID: fmt.Sprintf("GP-%d", time.Now().UnixNano()%10000000), + CustomerID: customerID, + Name: name, + Tier: models.TierBronze, + ReferralCode: strings.ToUpper(name[:min(4, len(name))]) + fmt.Sprintf("%d", time.Now().UnixNano()%10000), + LastActivityAt: time.Now(), + CreatedAt: time.Now(), + } + r.profiles[customerID] = p + return p +} + +func min(a, b int) int { if a < b { return a }; return b } + +func (r *GamificationRepository) GetProfile(customerID string) (*models.Profile, error) { + r.mu.RLock() + defer r.mu.RUnlock() + p, ok := r.profiles[customerID] + if !ok { + return nil, fmt.Errorf("profile not found for customer %s", customerID) + } + return p, nil +} + +func (r *GamificationRepository) UpdateProfile(p *models.Profile) { + r.mu.Lock() + defer r.mu.Unlock() + r.profiles[p.CustomerID] = p +} + +func (r *GamificationRepository) AddPointsTx(tx models.PointsTransaction) { + r.mu.Lock() + defer r.mu.Unlock() + r.pointsTx = append(r.pointsTx, tx) +} + +func (r *GamificationRepository) GetPointsHistory(profileID string) []models.PointsTransaction { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.PointsTransaction + for _, tx := range r.pointsTx { + if tx.ProfileID == profileID { + result = append(result, tx) + } + } + return result +} + +func (r *GamificationRepository) AwardBadge(profileID string, badge models.Badge) { + r.mu.Lock() + defer r.mu.Unlock() + r.earnedBadges[profileID] = append(r.earnedBadges[profileID], models.EarnedBadge{ + ProfileID: profileID, + BadgeID: badge.ID, + Badge: badge, + EarnedAt: time.Now(), + }) +} + +func (r *GamificationRepository) GetEarnedBadges(profileID string) []models.EarnedBadge { + r.mu.RLock() + defer r.mu.RUnlock() + return r.earnedBadges[profileID] +} + +func (r *GamificationRepository) GetBadges() []models.Badge { + var result []models.Badge + for _, b := range r.badges { + result = append(result, b) + } + return result +} + +func (r *GamificationRepository) GetChallenges() []models.Challenge { + var result []models.Challenge + for _, c := range r.challenges { + if c.IsActive { + result = append(result, c) + } + } + return result +} + +func (r *GamificationRepository) GetRewards() []models.Reward { + var result []models.Reward + for _, rw := range r.rewards { + if rw.IsAvailable { + result = append(result, rw) + } + } + return result +} + +func (r *GamificationRepository) GetReward(id string) (*models.Reward, error) { + rw, ok := r.rewards[id] + if !ok { return nil, fmt.Errorf("reward %s not found", id) } + return &rw, nil +} + +func (r *GamificationRepository) AddRedemption(red models.Redemption) { + r.mu.Lock() + defer r.mu.Unlock() + r.redemptions = append(r.redemptions, red) +} + +func (r *GamificationRepository) GetTiers() []models.TierConfig { return r.tiers } + +func (r *GamificationRepository) GetLeaderboard(limit int) []models.Profile { + r.mu.RLock() + defer r.mu.RUnlock() + var all []models.Profile + for _, p := range r.profiles { + all = append(all, *p) + } + for i := 0; i < len(all); i++ { + for j := i + 1; j < len(all); j++ { + if all[j].Points > all[i].Points { + all[i], all[j] = all[j], all[i] + } + } + } + if limit > 0 && len(all) > limit { + return all[:limit] + } + return all +} diff --git a/gamification-service/internal/service/service.go b/gamification-service/internal/service/service.go new file mode 100644 index 000000000..b1674c682 --- /dev/null +++ b/gamification-service/internal/service/service.go @@ -0,0 +1,144 @@ +package service + +import ( + "fmt" + "gamification-service/internal/models" + "gamification-service/internal/repository" + "time" +) + +type GamificationService struct { + repo *repository.GamificationRepository +} + +func NewGamificationService(repo *repository.GamificationRepository) *GamificationService { + return &GamificationService{repo: repo} +} + +type EarnRequest struct { + CustomerID string `json:"customer_id"` + Name string `json:"name"` + Action string `json:"action"` + Reference string `json:"reference,omitempty"` +} + +var actionPoints = map[string]int{ + "premium_payment": 100, + "on_time_payment": 150, + "referral": 300, + "profile_complete": 200, + "claim_documentation": 50, + "feedback_submitted": 75, + "kyc_verified": 250, + "policy_renewal": 200, + "app_login": 10, + "survey_completed": 100, +} + +func (s *GamificationService) EarnPoints(req EarnRequest) (*models.Profile, int, error) { + points, ok := actionPoints[req.Action] + if !ok { + return nil, 0, fmt.Errorf("unknown action: %s", req.Action) + } + + profile := s.repo.GetOrCreateProfile(req.CustomerID, req.Name) + profile.Points += points + profile.LifetimePoints += points + profile.LastActivityAt = time.Now() + + newTier := s.calculateTier(profile.LifetimePoints) + if newTier != profile.Tier { + profile.Tier = newTier + } + + if req.Action == "referral" { + profile.ReferralCount++ + } + + s.repo.UpdateProfile(profile) + + s.repo.AddPointsTx(models.PointsTransaction{ + ID: fmt.Sprintf("PTX-%d", time.Now().UnixNano()%10000000), + ProfileID: profile.ID, + Points: points, + Action: req.Action, + Reason: fmt.Sprintf("Earned %d points for %s", points, req.Action), + Reference: req.Reference, + CreatedAt: time.Now(), + }) + + return profile, points, nil +} + +func (s *GamificationService) calculateTier(lifetime int) models.TierLevel { + switch { + case lifetime >= 10000: + return models.TierPlatinum + case lifetime >= 5000: + return models.TierGold + case lifetime >= 2000: + return models.TierSilver + default: + return models.TierBronze + } +} + +type RedeemRequest struct { + CustomerID string `json:"customer_id"` + RewardID string `json:"reward_id"` +} + +func (s *GamificationService) RedeemReward(req RedeemRequest) (*models.Redemption, error) { + profile, err := s.repo.GetProfile(req.CustomerID) + if err != nil { + return nil, err + } + reward, err := s.repo.GetReward(req.RewardID) + if err != nil { + return nil, err + } + if profile.Points < reward.PointsCost { + return nil, fmt.Errorf("insufficient points: have %d, need %d", profile.Points, reward.PointsCost) + } + if !reward.IsAvailable { + return nil, fmt.Errorf("reward %s is not available", req.RewardID) + } + + profile.Points -= reward.PointsCost + profile.RedeemedPoints += reward.PointsCost + s.repo.UpdateProfile(profile) + + redemption := models.Redemption{ + ID: fmt.Sprintf("RDM-%d", time.Now().UnixNano()%10000000), + ProfileID: profile.ID, + RewardID: req.RewardID, + Points: reward.PointsCost, + Status: "fulfilled", + CreatedAt: time.Now(), + } + s.repo.AddRedemption(redemption) + + s.repo.AddPointsTx(models.PointsTransaction{ + ID: fmt.Sprintf("PTX-%d", time.Now().UnixNano()%10000000), + ProfileID: profile.ID, + Points: -reward.PointsCost, + Action: "redemption", + Reason: fmt.Sprintf("Redeemed %s for %d points", reward.Name, reward.PointsCost), + Reference: redemption.ID, + CreatedAt: time.Now(), + }) + + return &redemption, nil +} + +func (s *GamificationService) GetProfile(customerID string) (*models.Profile, error) { + return s.repo.GetProfile(customerID) +} + +func (s *GamificationService) GetBadges() []models.Badge { return s.repo.GetBadges() } +func (s *GamificationService) GetEarnedBadges(profileID string) []models.EarnedBadge { return s.repo.GetEarnedBadges(profileID) } +func (s *GamificationService) GetChallenges() []models.Challenge { return s.repo.GetChallenges() } +func (s *GamificationService) GetRewards() []models.Reward { return s.repo.GetRewards() } +func (s *GamificationService) GetLeaderboard(limit int) []models.Profile { return s.repo.GetLeaderboard(limit) } +func (s *GamificationService) GetTiers() []models.TierConfig { return s.repo.GetTiers() } +func (s *GamificationService) GetPointsHistory(profileID string) []models.PointsTransaction { return s.repo.GetPointsHistory(profileID) } diff --git a/instant-payout-service/cmd/server/main.go b/instant-payout-service/cmd/server/main.go index d3037ae7f..09eb0024b 100644 --- a/instant-payout-service/cmd/server/main.go +++ b/instant-payout-service/cmd/server/main.go @@ -1,12 +1,14 @@ package main import ( - "encoding/json" "fmt" "log" "net/http" "os" - "time" + + "instant-payout-service/internal/handlers" + "instant-payout-service/internal/repository" + "instant-payout-service/internal/service" ) func main() { @@ -14,110 +16,27 @@ func main() { if port == "" { port = "8101" } + + repo := repository.NewPayoutRepository() + svc := service.NewPayoutService(repo) + handler := handlers.NewHandler(svc) + mux := http.NewServeMux() - mux.HandleFunc("/api/v1/payouts/initiate", handleInitiatePayout) - mux.HandleFunc("/api/v1/payouts/batch", handleBatchPayout) - mux.HandleFunc("/api/v1/payouts/status/", handlePayoutStatus) - mux.HandleFunc("/api/v1/payouts/channels", handlePayoutChannels) + handler.RegisterRoutes(mux) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"instant-payout-service"}`)) + w.Write([]byte(`{"status":"healthy","service":"instant-payout-service","version":"2.0.0"}`)) }) - log.Printf("Instant Payout Service starting on port %s", port) - if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { - log.Fatal(err) - } -} - -type PayoutRequest struct { - ClaimID string `json:"claim_id"` - PolicyID string `json:"policy_id"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - Recipient string `json:"recipient_name"` - Channel string `json:"channel"` // mobile_money, bank_transfer, wallet - AccountRef string `json:"account_ref"` // phone number or bank account - Provider string `json:"provider,omitempty"` - Reason string `json:"reason"` -} - -type PayoutResponse struct { - PayoutID string `json:"payout_id"` - Status string `json:"status"` - Amount float64 `json:"amount"` - Currency string `json:"currency"` - Channel string `json:"channel"` - Reference string `json:"reference"` - EstimatedTime string `json:"estimated_time"` - CreatedAt time.Time `json:"created_at"` -} - -func handleInitiatePayout(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req PayoutRequest - json.NewDecoder(r.Body).Decode(&req) - if req.Currency == "" { - req.Currency = "NGN" - } - - payoutID := fmt.Sprintf("PYT-%d", time.Now().UnixNano()%10000000) - estimatedTime := "instant" - switch req.Channel { - case "mobile_money": - estimatedTime = "< 30 seconds" - case "bank_transfer": - estimatedTime = "< 5 minutes (NIBSS Instant Payment)" - case "wallet": - estimatedTime = "instant" - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(PayoutResponse{ - PayoutID: payoutID, - Status: "processing", - Amount: req.Amount, - Currency: req.Currency, - Channel: req.Channel, - Reference: fmt.Sprintf("NGA-PYT-%s", payoutID), - EstimatedTime: estimatedTime, - CreatedAt: time.Now(), + mux.HandleFunc("/ready", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + w.Write([]byte(`{"status":"ready"}`)) }) -} -func handleBatchPayout(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return + log.Printf("Instant Payout Service v2.0 starting on port %s", port) + if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { + log.Fatal(err) } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]interface{}{ - "batch_id": fmt.Sprintf("BATCH-%d", time.Now().UnixNano()%1000000), - "status": "queued", - "total_items": 0, - "message": "Batch payout queued for processing", - }) -} - -func handlePayoutStatus(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "completed", - "completed_at": time.Now().Format(time.RFC3339), - }) -} - -func handlePayoutChannels(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "channels": []map[string]interface{}{ - {"id": "mobile_money", "name": "Mobile Money", "providers": []string{"OPay", "PalmPay", "MTN MoMo", "Airtel Money"}, "speed": "instant", "limit": 5000000, "fee_pct": 0.5}, - {"id": "bank_transfer", "name": "Bank Transfer (NIBSS)", "providers": []string{"All Nigerian banks"}, "speed": "< 5 minutes", "limit": 50000000, "fee_pct": 0.25}, - {"id": "wallet", "name": "NGApp Wallet", "providers": []string{"NGApp"}, "speed": "instant", "limit": 10000000, "fee_pct": 0}, - }, - }) } diff --git a/instant-payout-service/go.mod b/instant-payout-service/go.mod index d79d1cd90..dd5e8c546 100644 --- a/instant-payout-service/go.mod +++ b/instant-payout-service/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/instant-payout-service +module instant-payout-service go 1.22.0 diff --git a/instant-payout-service/internal/handlers/handlers.go b/instant-payout-service/internal/handlers/handlers.go new file mode 100644 index 000000000..ba6379669 --- /dev/null +++ b/instant-payout-service/internal/handlers/handlers.go @@ -0,0 +1,108 @@ +package handlers + +import ( + "encoding/json" + "instant-payout-service/internal/service" + "net/http" + "strings" +) + +type Handler struct { + svc *service.PayoutService +} + +func NewHandler(svc *service.PayoutService) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/payouts/initiate", h.InitiatePayout) + mux.HandleFunc("/api/v1/payouts/batch", h.BatchPayout) + mux.HandleFunc("/api/v1/payouts/status/", h.PayoutStatus) + mux.HandleFunc("/api/v1/payouts/channels", h.PayoutChannels) + mux.HandleFunc("/api/v1/payouts/list", h.ListPayouts) + mux.HandleFunc("/api/v1/payouts/ledger/", h.PayoutLedger) + mux.HandleFunc("/api/v1/payouts/stats", h.PayoutStats) +} + +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func respondError(w http.ResponseWriter, status int, msg string) { + respondJSON(w, status, map[string]string{"error": msg}) +} + +func (h *Handler) InitiatePayout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + var req service.InitiateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body") + return + } + payout, err := h.svc.InitiatePayout(req) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusCreated, payout) +} + +func (h *Handler) BatchPayout(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + var requests []service.InitiateRequest + if err := json.NewDecoder(r.Body).Decode(&requests); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body") + return + } + batch, err := h.svc.InitiateBatch(requests) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusCreated, batch) +} + +func (h *Handler) PayoutStatus(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/payouts/status/") + if id == "" { + respondError(w, http.StatusBadRequest, "Payout ID required") + return + } + payout, err := h.svc.GetPayout(id) + if err != nil { + respondError(w, http.StatusNotFound, err.Error()) + return + } + respondJSON(w, http.StatusOK, payout) +} + +func (h *Handler) ListPayouts(w http.ResponseWriter, r *http.Request) { + status := r.URL.Query().Get("status") + payouts := h.svc.ListPayouts(status, 100) + respondJSON(w, http.StatusOK, map[string]interface{}{"payouts": payouts, "count": len(payouts)}) +} + +func (h *Handler) PayoutChannels(w http.ResponseWriter, r *http.Request) { + channels := h.svc.GetChannels() + respondJSON(w, http.StatusOK, map[string]interface{}{"channels": channels}) +} + +func (h *Handler) PayoutLedger(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/payouts/ledger/") + entries := h.svc.GetLedger(id) + respondJSON(w, http.StatusOK, map[string]interface{}{"entries": entries}) +} + +func (h *Handler) PayoutStats(w http.ResponseWriter, r *http.Request) { + stats := h.svc.GetStats() + respondJSON(w, http.StatusOK, stats) +} diff --git a/instant-payout-service/internal/models/payout.go b/instant-payout-service/internal/models/payout.go new file mode 100644 index 000000000..1e47734a8 --- /dev/null +++ b/instant-payout-service/internal/models/payout.go @@ -0,0 +1,84 @@ +package models + +import ( + "time" +) + +type PayoutStatus string + +const ( + PayoutPending PayoutStatus = "pending" + PayoutProcessing PayoutStatus = "processing" + PayoutCompleted PayoutStatus = "completed" + PayoutFailed PayoutStatus = "failed" + PayoutReversed PayoutStatus = "reversed" +) + +type PayoutChannel string + +const ( + ChannelMobileMoney PayoutChannel = "mobile_money" + ChannelBankTransfer PayoutChannel = "bank_transfer" + ChannelWallet PayoutChannel = "wallet" + ChannelUSSD PayoutChannel = "ussd" +) + +type Payout struct { + ID string `json:"id"` + ClaimID string `json:"claim_id"` + PolicyID string `json:"policy_id"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Channel PayoutChannel `json:"channel"` + Status PayoutStatus `json:"status"` + RecipientName string `json:"recipient_name"` + AccountRef string `json:"account_ref"` + Provider string `json:"provider"` + Reason string `json:"reason"` + Reference string `json:"reference"` + ProviderRef string `json:"provider_ref,omitempty"` + ErrorMessage string `json:"error_message,omitempty"` + FeeAmount float64 `json:"fee_amount"` + NetAmount float64 `json:"net_amount"` + ExchangeRate float64 `json:"exchange_rate,omitempty"` + EstimatedTime string `json:"estimated_time"` + BatchID string `json:"batch_id,omitempty"` + RetryCount int `json:"retry_count"` + MaxRetries int `json:"max_retries"` + CreatedAt time.Time `json:"created_at"` + ProcessedAt *time.Time `json:"processed_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +type BatchPayout struct { + ID string `json:"id"` + Payouts []Payout `json:"payouts"` + TotalAmount float64 `json:"total_amount"` + Currency string `json:"currency"` + Status PayoutStatus `json:"status"` + SuccessCount int `json:"success_count"` + FailCount int `json:"fail_count"` + CreatedAt time.Time `json:"created_at"` +} + +type ChannelConfig struct { + Channel PayoutChannel `json:"channel"` + Provider string `json:"provider"` + IsActive bool `json:"is_active"` + MaxAmount float64 `json:"max_amount"` + MinAmount float64 `json:"min_amount"` + FeePercent float64 `json:"fee_percent"` + FeeFlat float64 `json:"fee_flat"` + EstimatedTime string `json:"estimated_time"` + Currencies []string `json:"currencies"` +} + +type PayoutLedgerEntry struct { + ID string `json:"id"` + PayoutID string `json:"payout_id"` + Action string `json:"action"` + OldStatus string `json:"old_status"` + NewStatus string `json:"new_status"` + Details string `json:"details"` + CreatedAt time.Time `json:"created_at"` +} diff --git a/instant-payout-service/internal/repository/repository.go b/instant-payout-service/internal/repository/repository.go new file mode 100644 index 000000000..c8e4b4a47 --- /dev/null +++ b/instant-payout-service/internal/repository/repository.go @@ -0,0 +1,173 @@ +package repository + +import ( + "fmt" + "instant-payout-service/internal/models" + "sync" + "time" +) + +type PayoutRepository struct { + mu sync.RWMutex + payouts map[string]*models.Payout + batches map[string]*models.BatchPayout + ledger []models.PayoutLedgerEntry + channels []models.ChannelConfig +} + +func NewPayoutRepository() *PayoutRepository { + return &PayoutRepository{ + payouts: make(map[string]*models.Payout), + batches: make(map[string]*models.BatchPayout), + ledger: []models.PayoutLedgerEntry{}, + channels: []models.ChannelConfig{ + {Channel: models.ChannelMobileMoney, Provider: "OPay", IsActive: true, MaxAmount: 5000000, MinAmount: 100, FeePercent: 0.5, FeeFlat: 50, EstimatedTime: "< 30 seconds", Currencies: []string{"NGN"}}, + {Channel: models.ChannelMobileMoney, Provider: "Paystack", IsActive: true, MaxAmount: 10000000, MinAmount: 100, FeePercent: 0.4, FeeFlat: 100, EstimatedTime: "< 1 minute", Currencies: []string{"NGN", "GHS", "KES"}}, + {Channel: models.ChannelBankTransfer, Provider: "NIBSS", IsActive: true, MaxAmount: 50000000, MinAmount: 1000, FeePercent: 0.1, FeeFlat: 25, EstimatedTime: "< 5 minutes", Currencies: []string{"NGN"}}, + {Channel: models.ChannelBankTransfer, Provider: "Flutterwave", IsActive: true, MaxAmount: 25000000, MinAmount: 500, FeePercent: 0.3, FeeFlat: 50, EstimatedTime: "< 10 minutes", Currencies: []string{"NGN", "GHS", "KES", "ZAR", "UGX", "TZS"}}, + {Channel: models.ChannelWallet, Provider: "Internal", IsActive: true, MaxAmount: 1000000, MinAmount: 50, FeePercent: 0, FeeFlat: 0, EstimatedTime: "instant", Currencies: []string{"NGN", "USD", "GBP", "EUR"}}, + {Channel: models.ChannelUSSD, Provider: "AfricasTalking", IsActive: true, MaxAmount: 500000, MinAmount: 100, FeePercent: 0.8, FeeFlat: 25, EstimatedTime: "< 2 minutes", Currencies: []string{"NGN", "KES", "UGX"}}, + }, + } +} + +func (r *PayoutRepository) Create(p *models.Payout) error { + r.mu.Lock() + defer r.mu.Unlock() + r.payouts[p.ID] = p + r.addLedgerEntry(p.ID, "created", "", string(p.Status), fmt.Sprintf("Payout created: %s %.2f to %s via %s", p.Currency, p.Amount, p.RecipientName, p.Channel)) + return nil +} + +func (r *PayoutRepository) GetByID(id string) (*models.Payout, error) { + r.mu.RLock() + defer r.mu.RUnlock() + p, ok := r.payouts[id] + if !ok { + return nil, fmt.Errorf("payout %s not found", id) + } + return p, nil +} + +func (r *PayoutRepository) UpdateStatus(id string, status models.PayoutStatus, details string) error { + r.mu.Lock() + defer r.mu.Unlock() + p, ok := r.payouts[id] + if !ok { + return fmt.Errorf("payout %s not found", id) + } + old := string(p.Status) + p.Status = status + now := time.Now() + if status == models.PayoutProcessing { + p.ProcessedAt = &now + } + if status == models.PayoutCompleted || status == models.PayoutFailed { + p.CompletedAt = &now + } + r.addLedgerEntry(id, "status_change", old, string(status), details) + return nil +} + +func (r *PayoutRepository) List(status string, limit int) []models.Payout { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.Payout + for _, p := range r.payouts { + if status != "" && string(p.Status) != status { + continue + } + result = append(result, *p) + if limit > 0 && len(result) >= limit { + break + } + } + return result +} + +func (r *PayoutRepository) CreateBatch(b *models.BatchPayout) error { + r.mu.Lock() + defer r.mu.Unlock() + r.batches[b.ID] = b + return nil +} + +func (r *PayoutRepository) GetBatch(id string) (*models.BatchPayout, error) { + r.mu.RLock() + defer r.mu.RUnlock() + b, ok := r.batches[id] + if !ok { + return nil, fmt.Errorf("batch %s not found", id) + } + return b, nil +} + +func (r *PayoutRepository) GetChannels() []models.ChannelConfig { + return r.channels +} + +func (r *PayoutRepository) GetChannelConfig(channel models.PayoutChannel, provider string) *models.ChannelConfig { + for _, c := range r.channels { + if c.Channel == channel && (provider == "" || c.Provider == provider) && c.IsActive { + return &c + } + } + return nil +} + +func (r *PayoutRepository) GetLedger(payoutID string) []models.PayoutLedgerEntry { + r.mu.RLock() + defer r.mu.RUnlock() + var entries []models.PayoutLedgerEntry + for _, e := range r.ledger { + if e.PayoutID == payoutID { + entries = append(entries, e) + } + } + return entries +} + +func (r *PayoutRepository) addLedgerEntry(payoutID, action, oldStatus, newStatus, details string) { + r.ledger = append(r.ledger, models.PayoutLedgerEntry{ + ID: fmt.Sprintf("LED-%d", time.Now().UnixNano()%10000000), + PayoutID: payoutID, + Action: action, + OldStatus: oldStatus, + NewStatus: newStatus, + Details: details, + CreatedAt: time.Now(), + }) +} + +func (r *PayoutRepository) GetStats() map[string]interface{} { + r.mu.RLock() + defer r.mu.RUnlock() + total := len(r.payouts) + var completed, failed, processing int + var totalAmount, totalFees float64 + for _, p := range r.payouts { + switch p.Status { + case models.PayoutCompleted: + completed++ + totalAmount += p.NetAmount + totalFees += p.FeeAmount + case models.PayoutFailed: + failed++ + case models.PayoutProcessing: + processing++ + } + } + successRate := 0.0 + if total > 0 { + successRate = float64(completed) / float64(total) * 100 + } + return map[string]interface{}{ + "total_payouts": total, + "completed": completed, + "failed": failed, + "processing": processing, + "total_disbursed": totalAmount, + "total_fees": totalFees, + "success_rate_pct": successRate, + } +} diff --git a/instant-payout-service/internal/service/service.go b/instant-payout-service/internal/service/service.go new file mode 100644 index 000000000..f60851485 --- /dev/null +++ b/instant-payout-service/internal/service/service.go @@ -0,0 +1,193 @@ +package service + +import ( + "fmt" + "instant-payout-service/internal/models" + "instant-payout-service/internal/repository" + "math" + "strings" + "time" +) + +type PayoutService struct { + repo *repository.PayoutRepository +} + +func NewPayoutService(repo *repository.PayoutRepository) *PayoutService { + return &PayoutService{repo: repo} +} + +type InitiateRequest struct { + ClaimID string `json:"claim_id"` + PolicyID string `json:"policy_id"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + Channel string `json:"channel"` + AccountRef string `json:"account_ref"` + Recipient string `json:"recipient_name"` + Provider string `json:"provider,omitempty"` + Reason string `json:"reason"` +} + +func (s *PayoutService) InitiatePayout(req InitiateRequest) (*models.Payout, error) { + if req.Amount <= 0 { + return nil, fmt.Errorf("amount must be positive") + } + if req.AccountRef == "" { + return nil, fmt.Errorf("account_ref is required") + } + if req.Recipient == "" { + return nil, fmt.Errorf("recipient_name is required") + } + if req.Currency == "" { + req.Currency = "NGN" + } + + channel := models.PayoutChannel(req.Channel) + if channel == "" { + channel = models.ChannelBankTransfer + } + + cfg := s.repo.GetChannelConfig(channel, req.Provider) + if cfg == nil { + return nil, fmt.Errorf("channel %s (provider: %s) not available", channel, req.Provider) + } + if req.Amount < cfg.MinAmount { + return nil, fmt.Errorf("amount %.2f below minimum %.2f for %s", req.Amount, cfg.MinAmount, channel) + } + if req.Amount > cfg.MaxAmount { + return nil, fmt.Errorf("amount %.2f exceeds maximum %.2f for %s", req.Amount, cfg.MaxAmount, channel) + } + + currencyValid := false + for _, c := range cfg.Currencies { + if strings.EqualFold(c, req.Currency) { + currencyValid = true + break + } + } + if !currencyValid { + return nil, fmt.Errorf("currency %s not supported for channel %s", req.Currency, channel) + } + + fee := math.Round((req.Amount*cfg.FeePercent/100+cfg.FeeFlat)*100) / 100 + net := req.Amount - fee + + payout := &models.Payout{ + ID: fmt.Sprintf("PYT-%d", time.Now().UnixNano()%10000000), + ClaimID: req.ClaimID, + PolicyID: req.PolicyID, + Amount: req.Amount, + Currency: req.Currency, + Channel: channel, + Status: models.PayoutProcessing, + RecipientName: req.Recipient, + AccountRef: req.AccountRef, + Provider: cfg.Provider, + Reason: req.Reason, + Reference: fmt.Sprintf("NGA-PYT-%d", time.Now().UnixNano()%10000000), + FeeAmount: fee, + NetAmount: net, + EstimatedTime: cfg.EstimatedTime, + MaxRetries: 3, + CreatedAt: time.Now(), + } + + if err := s.repo.Create(payout); err != nil { + return nil, err + } + + go s.processPayoutAsync(payout.ID) + + return payout, nil +} + +func (s *PayoutService) processPayoutAsync(payoutID string) { + time.Sleep(2 * time.Second) + p, err := s.repo.GetByID(payoutID) + if err != nil { + return + } + + passed := s.runFraudChecks(p) + if !passed { + s.repo.UpdateStatus(payoutID, models.PayoutFailed, "Failed fraud/AML screening") + return + } + + s.repo.UpdateStatus(payoutID, models.PayoutCompleted, + fmt.Sprintf("Disbursed %s %.2f to %s via %s/%s", p.Currency, p.NetAmount, p.RecipientName, p.Channel, p.Provider)) +} + +func (s *PayoutService) runFraudChecks(p *models.Payout) bool { + if p.Amount > 10000000 { + return false + } + if p.Currency == "NGN" && p.Amount > 5000000 && p.Channel == models.ChannelMobileMoney { + return false + } + return true +} + +func (s *PayoutService) GetPayout(id string) (*models.Payout, error) { + return s.repo.GetByID(id) +} + +func (s *PayoutService) ListPayouts(status string, limit int) []models.Payout { + return s.repo.List(status, limit) +} + +func (s *PayoutService) InitiateBatch(requests []InitiateRequest) (*models.BatchPayout, error) { + if len(requests) == 0 { + return nil, fmt.Errorf("batch must contain at least one payout") + } + if len(requests) > 500 { + return nil, fmt.Errorf("batch size exceeds maximum of 500") + } + + batch := &models.BatchPayout{ + ID: fmt.Sprintf("BATCH-%d", time.Now().UnixNano()%10000000), + Status: models.PayoutProcessing, + CreatedAt: time.Now(), + } + + for _, req := range requests { + p, err := s.InitiatePayout(req) + if err != nil { + batch.FailCount++ + continue + } + p.BatchID = batch.ID + batch.Payouts = append(batch.Payouts, *p) + batch.TotalAmount += p.Amount + batch.SuccessCount++ + } + + if len(requests) > 0 { + batch.Currency = requests[0].Currency + } + if batch.FailCount == len(requests) { + batch.Status = models.PayoutFailed + } else { + batch.Status = models.PayoutCompleted + } + + s.repo.CreateBatch(batch) + return batch, nil +} + +func (s *PayoutService) GetBatch(id string) (*models.BatchPayout, error) { + return s.repo.GetBatch(id) +} + +func (s *PayoutService) GetChannels() []models.ChannelConfig { + return s.repo.GetChannels() +} + +func (s *PayoutService) GetLedger(payoutID string) []models.PayoutLedgerEntry { + return s.repo.GetLedger(payoutID) +} + +func (s *PayoutService) GetStats() map[string]interface{} { + return s.repo.GetStats() +} diff --git a/microinsurance-engine/cmd/server/main.go b/microinsurance-engine/cmd/server/main.go index eb4dac4be..478be4440 100644 --- a/microinsurance-engine/cmd/server/main.go +++ b/microinsurance-engine/cmd/server/main.go @@ -1,13 +1,14 @@ package main import ( - "encoding/json" "fmt" "log" - "math" "net/http" "os" - "time" + + "microinsurance-engine/internal/handlers" + "microinsurance-engine/internal/repository" + "microinsurance-engine/internal/service" ) func main() { @@ -15,250 +16,22 @@ func main() { if port == "" { port = "8094" } + + repo := repository.NewMicroRepository() + svc := service.NewMicroService(repo) + handler := handlers.NewHandler(svc) + mux := http.NewServeMux() - mux.HandleFunc("/api/v1/micro/products", handleProducts) - mux.HandleFunc("/api/v1/micro/enroll", handleEnroll) - mux.HandleFunc("/api/v1/micro/group-enroll", handleGroupEnroll) - mux.HandleFunc("/api/v1/micro/quote", handleQuote) - mux.HandleFunc("/api/v1/micro/claim", handleClaim) + handler.RegisterRoutes(mux) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"microinsurance-engine"}`)) + w.Write([]byte(`{"status":"healthy","service":"microinsurance-engine","version":"2.0.0"}`)) }) - log.Printf("Microinsurance Engine starting on port %s", port) + + log.Printf("Microinsurance Engine v2.0 starting on port %s", port) if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { log.Fatal(err) } } - -// MicroProduct represents a microinsurance product template -type MicroProduct struct { - ID string `json:"id"` - Name string `json:"name"` - Type string `json:"type"` - MinPremium float64 `json:"min_premium_ngn"` - MaxCoverage float64 `json:"max_coverage_ngn"` - PremiumFrequency string `json:"premium_frequency"` - EnrollmentTime string `json:"enrollment_time"` - MinKYCLevel string `json:"min_kyc_level"` // basic, standard, full - Features []string `json:"features"` - Exclusions []string `json:"exclusions"` - WaitingPeriod int `json:"waiting_period_days"` -} - -type EnrollRequest struct { - ProductID string `json:"product_id"` - CustomerName string `json:"customer_name"` - Phone string `json:"phone"` - DateOfBirth string `json:"date_of_birth,omitempty"` - Gender string `json:"gender,omitempty"` - PaymentMethod string `json:"payment_method"` - GroupID string `json:"group_id,omitempty"` -} - -type GroupEnrollRequest struct { - ProductID string `json:"product_id"` - GroupName string `json:"group_name"` - GroupType string `json:"group_type"` // church, cooperative, association, employer - LeaderName string `json:"leader_name"` - LeaderPhone string `json:"leader_phone"` - Members []GroupMember `json:"members"` -} - -type GroupMember struct { - Name string `json:"name"` - Phone string `json:"phone"` - Role string `json:"role,omitempty"` -} - -func handleProducts(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - products := []MicroProduct{ - { - ID: "MICRO-HC-001", Name: "Hospital Cash", Type: "health", - MinPremium: 500, MaxCoverage: 5000, PremiumFrequency: "daily", - EnrollmentTime: "< 2 minutes", MinKYCLevel: "basic", - Features: []string{ - "N5,000 per day hospitalization benefit", - "Up to 30 days per year", - "No medical exam required", - "Mobile money payment", - "Instant activation", - }, - Exclusions: []string{"Pre-existing conditions (first 90 days)", "Self-inflicted injuries"}, - WaitingPeriod: 30, - }, - { - ID: "MICRO-FN-001", Name: "Funeral Cover", Type: "funeral", - MinPremium: 500, MaxCoverage: 500000, PremiumFrequency: "monthly", - EnrollmentTime: "< 2 minutes", MinKYCLevel: "basic", - Features: []string{ - "N500,000 funeral benefit", - "Covers policyholder + 4 dependents", - "24-hour claims processing", - "Cash payout within 48 hours", - }, - Exclusions: []string{"Suicide (first 12 months)"}, - WaitingPeriod: 30, - }, - { - ID: "MICRO-DV-001", Name: "Device Protect", Type: "device", - MinPremium: 200, MaxCoverage: 300000, PremiumFrequency: "monthly", - EnrollmentTime: "< 1 minute", MinKYCLevel: "basic", - Features: []string{ - "Covers theft and accidental damage", - "Replacement within 48 hours", - "Embedded at point of sale", - }, - Exclusions: []string{"Cosmetic damage", "Loss/misplacement"}, - WaitingPeriod: 0, - }, - { - ID: "MICRO-CL-001", Name: "Credit Life", Type: "credit_life", - MinPremium: 100, MaxCoverage: 1000000, PremiumFrequency: "per_loan", - EnrollmentTime: "automatic", MinKYCLevel: "basic", - Features: []string{ - "Covers outstanding loan on death/disability", - "Embedded in microfinance loans", - "Premium included in loan repayment", - "Automatic enrollment", - }, - Exclusions: []string{"Loan default prior to event"}, - WaitingPeriod: 0, - }, - { - ID: "MICRO-CR-001", Name: "Crop Shield", Type: "crop", - MinPremium: 1000, MaxCoverage: 500000, PremiumFrequency: "seasonal", - EnrollmentTime: "< 5 minutes", MinKYCLevel: "standard", - Features: []string{ - "Parametric (satellite rainfall trigger)", - "Automatic payout - no claims process", - "Covers drought and excess rainfall", - "Seasonal coverage (planting to harvest)", - }, - Exclusions: []string{"Pest damage (separate product)"}, - WaitingPeriod: 0, - }, - } - json.NewEncoder(w).Encode(map[string]interface{}{"products": products}) -} - -func handleQuote(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { - ProductID string `json:"product_id"` - Age int `json:"age,omitempty"` - Amount float64 `json:"coverage_amount,omitempty"` - } - json.NewDecoder(r.Body).Decode(&req) - - basePremium := 500.0 - switch req.ProductID { - case "MICRO-HC-001": - basePremium = 500 + float64(max(0, req.Age-30))*10 - case "MICRO-FN-001": - basePremium = 500 + float64(max(0, req.Age-25))*15 - case "MICRO-DV-001": - if req.Amount > 0 { - basePremium = req.Amount * 0.005 - } else { - basePremium = 200 - } - case "MICRO-CL-001": - if req.Amount > 0 { - basePremium = req.Amount * 0.003 - } else { - basePremium = 100 - } - case "MICRO-CR-001": - basePremium = 1000 - } - basePremium = math.Round(basePremium*100) / 100 - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "product_id": req.ProductID, - "premium": basePremium, - "currency": "NGN", - "valid_until": time.Now().Add(24 * time.Hour).Format(time.RFC3339), - }) -} - -func handleEnroll(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req EnrollRequest - json.NewDecoder(r.Body).Decode(&req) - - policyNum := fmt.Sprintf("NGA-MIC-%d", time.Now().UnixNano()%1000000) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]interface{}{ - "policy_number": policyNum, - "status": "active", - "product_id": req.ProductID, - "customer_name": req.CustomerName, - "phone": req.Phone, - "enrolled_at": time.Now().Format(time.RFC3339), - "message": "Welcome! Your microinsurance is now active. Details sent via SMS.", - }) -} - -func handleGroupEnroll(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req GroupEnrollRequest - json.NewDecoder(r.Body).Decode(&req) - - groupID := fmt.Sprintf("GRP-%d", time.Now().UnixNano()%1000000) - memberPolicies := make([]map[string]string, len(req.Members)) - for i, m := range req.Members { - memberPolicies[i] = map[string]string{ - "name": m.Name, - "phone": m.Phone, - "policy_number": fmt.Sprintf("NGA-GRP-%s-%03d", groupID[4:], i+1), - "status": "active", - } - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]interface{}{ - "group_id": groupID, - "group_name": req.GroupName, - "group_type": req.GroupType, - "member_count": len(req.Members), - "member_policies": memberPolicies, - "status": "active", - "message": fmt.Sprintf("Group '%s' enrolled with %d members", req.GroupName, len(req.Members)), - }) -} - -func handleClaim(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]interface{}{ - "claim_number": fmt.Sprintf("NGA-MCL-%d", time.Now().UnixNano()%1000000), - "status": "submitted", - "expected_decision": "within 4 hours", - "message": "Claim submitted. For microinsurance claims under N50,000, expect auto-approval within 4 hours.", - }) -} - -func max(a, b int) int { - if a > b { - return a - } - return b -} diff --git a/microinsurance-engine/go.mod b/microinsurance-engine/go.mod index 7707de96b..3ea308c9d 100644 --- a/microinsurance-engine/go.mod +++ b/microinsurance-engine/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/microinsurance-engine +module microinsurance-engine go 1.22.0 diff --git a/microinsurance-engine/internal/handlers/handlers.go b/microinsurance-engine/internal/handlers/handlers.go new file mode 100644 index 000000000..12a9a9767 --- /dev/null +++ b/microinsurance-engine/internal/handlers/handlers.go @@ -0,0 +1,117 @@ +package handlers + +import ( + "encoding/json" + "microinsurance-engine/internal/service" + "net/http" + "strings" +) + +type Handler struct { + svc *service.MicroService +} + +func NewHandler(svc *service.MicroService) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/microinsurance/products", h.GetProducts) + mux.HandleFunc("/api/v1/microinsurance/product/", h.GetProduct) + mux.HandleFunc("/api/v1/microinsurance/enroll", h.Enroll) + mux.HandleFunc("/api/v1/microinsurance/policy/", h.GetPolicy) + mux.HandleFunc("/api/v1/microinsurance/policies", h.ListPolicies) + mux.HandleFunc("/api/v1/microinsurance/claim", h.FileClaim) + mux.HandleFunc("/api/v1/microinsurance/claim/", h.GetClaim) + mux.HandleFunc("/api/v1/microinsurance/stats", h.GetStats) +} + +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func respondError(w http.ResponseWriter, status int, msg string) { + respondJSON(w, status, map[string]string{"error": msg}) +} + +func (h *Handler) GetProducts(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, map[string]interface{}{"products": h.svc.GetProducts()}) +} + +func (h *Handler) GetProduct(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/microinsurance/product/") + p, err := h.svc.GetProduct(id) + if err != nil { + respondError(w, http.StatusNotFound, err.Error()) + return + } + respondJSON(w, http.StatusOK, p) +} + +func (h *Handler) Enroll(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + var req service.EnrollRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body") + return + } + policy, err := h.svc.Enroll(req) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusCreated, policy) +} + +func (h *Handler) GetPolicy(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/microinsurance/policy/") + p, err := h.svc.GetPolicy(id) + if err != nil { + respondError(w, http.StatusNotFound, err.Error()) + return + } + respondJSON(w, http.StatusOK, p) +} + +func (h *Handler) ListPolicies(w http.ResponseWriter, r *http.Request) { + customerID := r.URL.Query().Get("customer_id") + policies := h.svc.ListPolicies(customerID) + respondJSON(w, http.StatusOK, map[string]interface{}{"policies": policies, "count": len(policies)}) +} + +func (h *Handler) FileClaim(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + var req service.ClaimRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body") + return + } + claim, err := h.svc.FileClaim(req) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusCreated, claim) +} + +func (h *Handler) GetClaim(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/microinsurance/claim/") + c, err := h.svc.GetClaim(id) + if err != nil { + respondError(w, http.StatusNotFound, err.Error()) + return + } + respondJSON(w, http.StatusOK, c) +} + +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, h.svc.GetStats()) +} diff --git a/microinsurance-engine/internal/models/microinsurance.go b/microinsurance-engine/internal/models/microinsurance.go new file mode 100644 index 000000000..6268763b9 --- /dev/null +++ b/microinsurance-engine/internal/models/microinsurance.go @@ -0,0 +1,64 @@ +package models + +import "time" + +type ProductType string + +const ( + ProductCropInsurance ProductType = "crop_insurance" + ProductLivestockCover ProductType = "livestock_cover" + ProductDeviceProtection ProductType = "device_protection" + ProductHealthMicro ProductType = "health_micro" + ProductTravelMicro ProductType = "travel_micro" + ProductFuneralCover ProductType = "funeral_cover" + ProductAccidentCover ProductType = "personal_accident" +) + +type MicroProduct struct { + ID string `json:"id"` + Name string `json:"name"` + Type ProductType `json:"type"` + Description string `json:"description"` + MinPremium float64 `json:"min_premium"` + MaxPremium float64 `json:"max_premium"` + CoverageAmount float64 `json:"coverage_amount"` + DurationDays int `json:"duration_days"` + Currency string `json:"currency"` + IsActive bool `json:"is_active"` + RiskMultiplier float64 `json:"risk_multiplier"` + ClaimWaitDays int `json:"claim_wait_days"` + AutoRenew bool `json:"auto_renew"` + MaxClaimsPerYear int `json:"max_claims_per_year"` +} + +type MicroPolicy struct { + ID string `json:"id"` + ProductID string `json:"product_id"` + CustomerID string `json:"customer_id"` + CustomerName string `json:"customer_name"` + CustomerPhone string `json:"customer_phone"` + Premium float64 `json:"premium"` + CoverageAmount float64 `json:"coverage_amount"` + Currency string `json:"currency"` + Status string `json:"status"` + StartDate time.Time `json:"start_date"` + EndDate time.Time `json:"end_date"` + ClaimsCount int `json:"claims_count"` + Channel string `json:"channel"` + PaymentRef string `json:"payment_ref"` + CreatedAt time.Time `json:"created_at"` +} + +type MicroClaim struct { + ID string `json:"id"` + PolicyID string `json:"policy_id"` + CustomerID string `json:"customer_id"` + Amount float64 `json:"amount"` + Description string `json:"description"` + Evidence string `json:"evidence"` + Status string `json:"status"` + ReviewNotes string `json:"review_notes,omitempty"` + PayoutRef string `json:"payout_ref,omitempty"` + CreatedAt time.Time `json:"created_at"` + ResolvedAt *time.Time `json:"resolved_at,omitempty"` +} diff --git a/microinsurance-engine/internal/repository/repository.go b/microinsurance-engine/internal/repository/repository.go new file mode 100644 index 000000000..7f1b0527a --- /dev/null +++ b/microinsurance-engine/internal/repository/repository.go @@ -0,0 +1,159 @@ +package repository + +import ( + "fmt" + "microinsurance-engine/internal/models" + "sync" + "time" +) + +type MicroRepository struct { + mu sync.RWMutex + products map[string]*models.MicroProduct + policies map[string]*models.MicroPolicy + claims map[string]*models.MicroClaim +} + +func NewMicroRepository() *MicroRepository { + repo := &MicroRepository{ + products: make(map[string]*models.MicroProduct), + policies: make(map[string]*models.MicroPolicy), + claims: make(map[string]*models.MicroClaim), + } + repo.seedProducts() + return repo +} + +func (r *MicroRepository) seedProducts() { + products := []models.MicroProduct{ + {ID: "MP-001", Name: "Crop Shield", Type: models.ProductCropInsurance, Description: "Covers crop loss from drought, flood, pest", MinPremium: 200, MaxPremium: 5000, CoverageAmount: 50000, DurationDays: 180, Currency: "NGN", IsActive: true, RiskMultiplier: 1.2, ClaimWaitDays: 7, MaxClaimsPerYear: 2}, + {ID: "MP-002", Name: "Livestock Guard", Type: models.ProductLivestockCover, Description: "Covers livestock death/theft", MinPremium: 500, MaxPremium: 10000, CoverageAmount: 100000, DurationDays: 365, Currency: "NGN", IsActive: true, RiskMultiplier: 1.5, ClaimWaitDays: 14, MaxClaimsPerYear: 1}, + {ID: "MP-003", Name: "Device Safe", Type: models.ProductDeviceProtection, Description: "Phone/device screen damage and theft", MinPremium: 100, MaxPremium: 3000, CoverageAmount: 25000, DurationDays: 90, Currency: "NGN", IsActive: true, RiskMultiplier: 0.8, ClaimWaitDays: 3, MaxClaimsPerYear: 3}, + {ID: "MP-004", Name: "Health Lite", Type: models.ProductHealthMicro, Description: "Basic outpatient and pharmacy cover", MinPremium: 300, MaxPremium: 8000, CoverageAmount: 75000, DurationDays: 30, Currency: "NGN", IsActive: true, RiskMultiplier: 1.0, ClaimWaitDays: 0, MaxClaimsPerYear: 12}, + {ID: "MP-005", Name: "Travel Safe", Type: models.ProductTravelMicro, Description: "Trip cancellation and medical abroad", MinPremium: 500, MaxPremium: 15000, CoverageAmount: 200000, DurationDays: 30, Currency: "NGN", IsActive: true, RiskMultiplier: 0.6, ClaimWaitDays: 5, MaxClaimsPerYear: 2}, + {ID: "MP-006", Name: "Farewell Plan", Type: models.ProductFuneralCover, Description: "Funeral expense coverage", MinPremium: 200, MaxPremium: 5000, CoverageAmount: 100000, DurationDays: 365, Currency: "NGN", IsActive: true, RiskMultiplier: 0.3, ClaimWaitDays: 0, MaxClaimsPerYear: 1}, + {ID: "MP-007", Name: "Accident Shield", Type: models.ProductAccidentCover, Description: "Personal accident death/disability", MinPremium: 150, MaxPremium: 4000, CoverageAmount: 150000, DurationDays: 365, Currency: "NGN", IsActive: true, RiskMultiplier: 0.5, ClaimWaitDays: 7, MaxClaimsPerYear: 1}, + } + for i := range products { + r.products[products[i].ID] = &products[i] + } +} + +func (r *MicroRepository) GetProducts() []models.MicroProduct { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.MicroProduct + for _, p := range r.products { + if p.IsActive { + result = append(result, *p) + } + } + return result +} + +func (r *MicroRepository) GetProduct(id string) (*models.MicroProduct, error) { + r.mu.RLock() + defer r.mu.RUnlock() + p, ok := r.products[id] + if !ok { + return nil, fmt.Errorf("product %s not found", id) + } + return p, nil +} + +func (r *MicroRepository) CreatePolicy(p *models.MicroPolicy) error { + r.mu.Lock() + defer r.mu.Unlock() + r.policies[p.ID] = p + return nil +} + +func (r *MicroRepository) GetPolicy(id string) (*models.MicroPolicy, error) { + r.mu.RLock() + defer r.mu.RUnlock() + p, ok := r.policies[id] + if !ok { + return nil, fmt.Errorf("policy %s not found", id) + } + return p, nil +} + +func (r *MicroRepository) ListPolicies(customerID string) []models.MicroPolicy { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.MicroPolicy + for _, p := range r.policies { + if customerID == "" || p.CustomerID == customerID { + result = append(result, *p) + } + } + return result +} + +func (r *MicroRepository) CreateClaim(c *models.MicroClaim) error { + r.mu.Lock() + defer r.mu.Unlock() + r.claims[c.ID] = c + return nil +} + +func (r *MicroRepository) GetClaim(id string) (*models.MicroClaim, error) { + r.mu.RLock() + defer r.mu.RUnlock() + c, ok := r.claims[id] + if !ok { + return nil, fmt.Errorf("claim %s not found", id) + } + return c, nil +} + +func (r *MicroRepository) UpdateClaim(c *models.MicroClaim) error { + r.mu.Lock() + defer r.mu.Unlock() + r.claims[c.ID] = c + return nil +} + +func (r *MicroRepository) CountClaimsForPolicy(policyID string) int { + r.mu.RLock() + defer r.mu.RUnlock() + count := 0 + for _, c := range r.claims { + if c.PolicyID == policyID { + count++ + } + } + return count +} + +func (r *MicroRepository) GetStats() map[string]interface{} { + r.mu.RLock() + defer r.mu.RUnlock() + activePolicies := 0 + totalPremiums := 0.0 + totalClaims := len(r.claims) + approvedClaims := 0 + claimsPaid := 0.0 + now := time.Now() + for _, p := range r.policies { + if p.Status == "active" && now.Before(p.EndDate) { + activePolicies++ + } + totalPremiums += p.Premium + } + for _, c := range r.claims { + if c.Status == "approved" || c.Status == "paid" { + approvedClaims++ + claimsPaid += c.Amount + } + } + return map[string]interface{}{ + "total_products": len(r.products), + "active_policies": activePolicies, + "total_premiums": totalPremiums, + "total_claims": totalClaims, + "approved_claims": approvedClaims, + "total_claims_paid": claimsPaid, + "loss_ratio": claimsPaid / (totalPremiums + 0.01) * 100, + } +} diff --git a/microinsurance-engine/internal/service/service.go b/microinsurance-engine/internal/service/service.go new file mode 100644 index 000000000..c25f20d64 --- /dev/null +++ b/microinsurance-engine/internal/service/service.go @@ -0,0 +1,176 @@ +package service + +import ( + "fmt" + "math" + "microinsurance-engine/internal/models" + "microinsurance-engine/internal/repository" + "time" +) + +type MicroService struct { + repo *repository.MicroRepository +} + +func NewMicroService(repo *repository.MicroRepository) *MicroService { + return &MicroService{repo: repo} +} + +type EnrollRequest struct { + ProductID string `json:"product_id"` + CustomerID string `json:"customer_id"` + CustomerName string `json:"customer_name"` + CustomerPhone string `json:"customer_phone"` + Channel string `json:"channel"` + PaymentRef string `json:"payment_ref"` +} + +func (s *MicroService) Enroll(req EnrollRequest) (*models.MicroPolicy, error) { + product, err := s.repo.GetProduct(req.ProductID) + if err != nil { + return nil, err + } + if !product.IsActive { + return nil, fmt.Errorf("product %s is not active", req.ProductID) + } + if req.CustomerPhone == "" { + return nil, fmt.Errorf("customer phone is required for microinsurance") + } + + premium := s.calculatePremium(product, req.Channel) + now := time.Now() + + policy := &models.MicroPolicy{ + ID: fmt.Sprintf("MIP-%d", time.Now().UnixNano()%10000000), + ProductID: product.ID, + CustomerID: req.CustomerID, + CustomerName: req.CustomerName, + CustomerPhone: req.CustomerPhone, + Premium: premium, + CoverageAmount: product.CoverageAmount, + Currency: product.Currency, + Status: "active", + StartDate: now, + EndDate: now.AddDate(0, 0, product.DurationDays), + Channel: req.Channel, + PaymentRef: req.PaymentRef, + CreatedAt: now, + } + + if err := s.repo.CreatePolicy(policy); err != nil { + return nil, err + } + return policy, nil +} + +func (s *MicroService) calculatePremium(product *models.MicroProduct, channel string) float64 { + base := product.MinPremium + premium := base * product.RiskMultiplier + + switch channel { + case "ussd": + premium *= 0.90 + case "whatsapp": + premium *= 0.95 + case "agent": + premium *= 1.05 + } + + premium = math.Round(premium/50) * 50 + if premium < product.MinPremium { + premium = product.MinPremium + } + if premium > product.MaxPremium { + premium = product.MaxPremium + } + return premium +} + +type ClaimRequest struct { + PolicyID string `json:"policy_id"` + Amount float64 `json:"amount"` + Description string `json:"description"` + Evidence string `json:"evidence"` +} + +func (s *MicroService) FileClaim(req ClaimRequest) (*models.MicroClaim, error) { + policy, err := s.repo.GetPolicy(req.PolicyID) + if err != nil { + return nil, err + } + if policy.Status != "active" { + return nil, fmt.Errorf("policy %s is not active", req.PolicyID) + } + if time.Now().After(policy.EndDate) { + return nil, fmt.Errorf("policy %s has expired", req.PolicyID) + } + + product, _ := s.repo.GetProduct(policy.ProductID) + if product != nil { + claimCount := s.repo.CountClaimsForPolicy(req.PolicyID) + if claimCount >= product.MaxClaimsPerYear { + return nil, fmt.Errorf("maximum claims (%d) reached for this policy", product.MaxClaimsPerYear) + } + if product.ClaimWaitDays > 0 { + waitUntil := policy.StartDate.AddDate(0, 0, product.ClaimWaitDays) + if time.Now().Before(waitUntil) { + return nil, fmt.Errorf("claim waiting period: cannot claim until %s", waitUntil.Format("2006-01-02")) + } + } + } + + if req.Amount > policy.CoverageAmount { + return nil, fmt.Errorf("claim amount %.2f exceeds coverage %.2f", req.Amount, policy.CoverageAmount) + } + if req.Amount <= 0 { + return nil, fmt.Errorf("claim amount must be positive") + } + + status := "pending" + reviewNotes := "" + if req.Amount <= 5000 && req.Evidence != "" { + status = "auto_approved" + reviewNotes = "Auto-approved: low value claim with evidence" + } + + claim := &models.MicroClaim{ + ID: fmt.Sprintf("MIC-%d", time.Now().UnixNano()%10000000), + PolicyID: req.PolicyID, + CustomerID: policy.CustomerID, + Amount: req.Amount, + Description: req.Description, + Evidence: req.Evidence, + Status: status, + ReviewNotes: reviewNotes, + CreatedAt: time.Now(), + } + + if err := s.repo.CreateClaim(claim); err != nil { + return nil, err + } + return claim, nil +} + +func (s *MicroService) GetProducts() []models.MicroProduct { + return s.repo.GetProducts() +} + +func (s *MicroService) GetProduct(id string) (*models.MicroProduct, error) { + return s.repo.GetProduct(id) +} + +func (s *MicroService) GetPolicy(id string) (*models.MicroPolicy, error) { + return s.repo.GetPolicy(id) +} + +func (s *MicroService) ListPolicies(customerID string) []models.MicroPolicy { + return s.repo.ListPolicies(customerID) +} + +func (s *MicroService) GetClaim(id string) (*models.MicroClaim, error) { + return s.repo.GetClaim(id) +} + +func (s *MicroService) GetStats() map[string]interface{} { + return s.repo.GetStats() +} diff --git a/mobile-money-service/cmd/server/main.go b/mobile-money-service/cmd/server/main.go index 9c97073a0..d2002b0c2 100644 --- a/mobile-money-service/cmd/server/main.go +++ b/mobile-money-service/cmd/server/main.go @@ -5,6 +5,10 @@ import ( "log" "net/http" "os" + + "mobile-money-service/internal/handlers" + "mobile-money-service/internal/repository" + "mobile-money-service/internal/service" ) func main() { @@ -13,19 +17,18 @@ func main() { port = "8092" } - mux := http.NewServeMux() - handler := NewPaymentHandler() + repo := repository.NewMoMoRepository() + svc := service.NewMoMoService(repo) + h := handlers.NewHandler(svc) - mux.HandleFunc("/api/v1/payments/initiate", handler.InitiatePayment) - mux.HandleFunc("/api/v1/payments/callback", handler.PaymentCallback) - mux.HandleFunc("/api/v1/payments/status/", handler.GetPaymentStatus) - mux.HandleFunc("/api/v1/payments/recurring", handler.SetupRecurring) + mux := http.NewServeMux() + h.RegisterRoutes(mux) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"mobile-money-service"}`)) + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"healthy","service":"mobile-money-service","version":"2.0.0"}`)) }) - log.Printf("Mobile Money Service starting on port %s", port) + log.Printf("Mobile Money Service v2.0 starting on port %s", port) if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { log.Fatal(err) } diff --git a/mobile-money-service/go.mod b/mobile-money-service/go.mod index 6a92d8fa9..30c14edd3 100644 --- a/mobile-money-service/go.mod +++ b/mobile-money-service/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/mobile-money-service +module mobile-money-service go 1.22.0 diff --git a/mobile-money-service/internal/handlers/handlers.go b/mobile-money-service/internal/handlers/handlers.go new file mode 100644 index 000000000..d7cbdaefb --- /dev/null +++ b/mobile-money-service/internal/handlers/handlers.go @@ -0,0 +1,55 @@ +package handlers + +import ( + "encoding/json" + "mobile-money-service/internal/service" + "net/http" + "strings" +) + +type Handler struct { svc *service.MoMoService } +func NewHandler(svc *service.MoMoService) *Handler { return &Handler{svc: svc} } + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/momo/pay", h.Pay) + mux.HandleFunc("/api/v1/momo/disburse", h.Disburse) + mux.HandleFunc("/api/v1/momo/transaction/", h.GetTransaction) + mux.HandleFunc("/api/v1/momo/transactions", h.ListTransactions) + mux.HandleFunc("/api/v1/momo/providers", h.GetProviders) + mux.HandleFunc("/api/v1/momo/stats", h.GetStats) +} + +func rj(w http.ResponseWriter, s int, d interface{}) { w.Header().Set("Content-Type","application/json"); w.WriteHeader(s); json.NewEncoder(w).Encode(d) } +func re(w http.ResponseWriter, s int, m string) { rj(w, s, map[string]string{"error": m}) } + +func (h *Handler) Pay(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { re(w, 405, "Method not allowed"); return } + var req service.PayRequest + json.NewDecoder(r.Body).Decode(&req) + tx, err := h.svc.InitiatePayment(req) + if err != nil { re(w, 400, err.Error()); return } + rj(w, 201, tx) +} +func (h *Handler) Disburse(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { re(w, 405, "Method not allowed"); return } + var req service.DisbursementRequest + json.NewDecoder(r.Body).Decode(&req) + tx, err := h.svc.Disburse(req) + if err != nil { re(w, 400, err.Error()); return } + rj(w, 201, tx) +} +func (h *Handler) GetTransaction(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/momo/transaction/") + tx, err := h.svc.GetTransaction(id) + if err != nil { re(w, 404, err.Error()); return } + rj(w, 200, tx) +} +func (h *Handler) ListTransactions(w http.ResponseWriter, r *http.Request) { + phone := r.URL.Query().Get("phone") + rj(w, 200, map[string]interface{}{"transactions": h.svc.ListTransactions(phone)}) +} +func (h *Handler) GetProviders(w http.ResponseWriter, r *http.Request) { + country := r.URL.Query().Get("country") + rj(w, 200, map[string]interface{}{"providers": h.svc.GetProviders(country)}) +} +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { rj(w, 200, h.svc.GetStats()) } diff --git a/mobile-money-service/internal/models/mobilemoney.go b/mobile-money-service/internal/models/mobilemoney.go new file mode 100644 index 000000000..967d83c80 --- /dev/null +++ b/mobile-money-service/internal/models/mobilemoney.go @@ -0,0 +1,43 @@ +package models + +import "time" + +type Provider struct { + ID string `json:"id"` + Name string `json:"name"` + Code string `json:"code"` + Country string `json:"country"` + Currency string `json:"currency"` + FeePercent float64 `json:"fee_percent"` + FeeFlat float64 `json:"fee_flat"` + MinAmount float64 `json:"min_amount"` + MaxAmount float64 `json:"max_amount"` + IsActive bool `json:"is_active"` + SettleTime string `json:"settlement_time"` +} + +type MoMoTransaction struct { + ID string `json:"id"` + Type string `json:"type"` + ProviderCode string `json:"provider_code"` + Phone string `json:"phone"` + Amount float64 `json:"amount"` + Fee float64 `json:"fee"` + NetAmount float64 `json:"net_amount"` + Currency string `json:"currency"` + Reference string `json:"reference"` + PolicyID string `json:"policy_id,omitempty"` + ClaimID string `json:"claim_id,omitempty"` + Status string `json:"status"` + ProviderRef string `json:"provider_ref,omitempty"` + FailReason string `json:"fail_reason,omitempty"` + CreatedAt time.Time `json:"created_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +type WalletBalance struct { + CustomerID string `json:"customer_id"` + Phone string `json:"phone"` + Balance float64 `json:"balance"` + Currency string `json:"currency"` +} diff --git a/mobile-money-service/internal/repository/repository.go b/mobile-money-service/internal/repository/repository.go new file mode 100644 index 000000000..e23f07c7e --- /dev/null +++ b/mobile-money-service/internal/repository/repository.go @@ -0,0 +1,110 @@ +package repository + +import ( + "fmt" + "mobile-money-service/internal/models" + "sync" + "time" +) + +type MoMoRepository struct { + mu sync.RWMutex + providers map[string]models.Provider + transactions map[string]*models.MoMoTransaction + wallets map[string]*models.WalletBalance +} + +func NewMoMoRepository() *MoMoRepository { + repo := &MoMoRepository{ + providers: make(map[string]models.Provider), + transactions: make(map[string]*models.MoMoTransaction), + wallets: make(map[string]*models.WalletBalance), + } + repo.seedProviders() + return repo +} + +func (r *MoMoRepository) seedProviders() { + providers := []models.Provider{ + {ID: "PRV-001", Name: "OPay", Code: "opay", Country: "NG", Currency: "NGN", FeePercent: 0.5, FeeFlat: 10, MinAmount: 100, MaxAmount: 5000000, IsActive: true, SettleTime: "instant"}, + {ID: "PRV-002", Name: "Paystack", Code: "paystack", Country: "NG", Currency: "NGN", FeePercent: 1.5, FeeFlat: 100, MinAmount: 100, MaxAmount: 10000000, IsActive: true, SettleTime: "T+1"}, + {ID: "PRV-003", Name: "M-Pesa", Code: "mpesa", Country: "KE", Currency: "KES", FeePercent: 0.3, FeeFlat: 0, MinAmount: 10, MaxAmount: 300000, IsActive: true, SettleTime: "instant"}, + {ID: "PRV-004", Name: "MTN MoMo", Code: "mtn_momo", Country: "GH", Currency: "GHS", FeePercent: 1.0, FeeFlat: 0, MinAmount: 1, MaxAmount: 50000, IsActive: true, SettleTime: "instant"}, + {ID: "PRV-005", Name: "Flutterwave", Code: "flutterwave", Country: "NG", Currency: "NGN", FeePercent: 1.4, FeeFlat: 0, MinAmount: 100, MaxAmount: 10000000, IsActive: true, SettleTime: "T+1"}, + {ID: "PRV-006", Name: "NIBSS", Code: "nibss", Country: "NG", Currency: "NGN", FeePercent: 0.1, FeeFlat: 25, MinAmount: 1000, MaxAmount: 50000000, IsActive: true, SettleTime: "T+0"}, + } + for _, p := range providers { + r.providers[p.Code] = p + } +} + +func (r *MoMoRepository) GetProviders(country string) []models.Provider { + var result []models.Provider + for _, p := range r.providers { + if (country == "" || p.Country == country) && p.IsActive { result = append(result, p) } + } + return result +} + +func (r *MoMoRepository) GetProvider(code string) (*models.Provider, error) { + p, ok := r.providers[code] + if !ok { return nil, fmt.Errorf("provider %s not found", code) } + return &p, nil +} + +func (r *MoMoRepository) CreateTransaction(t *models.MoMoTransaction) { + r.mu.Lock() + defer r.mu.Unlock() + r.transactions[t.ID] = t +} + +func (r *MoMoRepository) GetTransaction(id string) (*models.MoMoTransaction, error) { + r.mu.RLock() + defer r.mu.RUnlock() + t, ok := r.transactions[id] + if !ok { return nil, fmt.Errorf("transaction %s not found", id) } + return t, nil +} + +func (r *MoMoRepository) UpdateTransaction(t *models.MoMoTransaction) { + r.mu.Lock() + defer r.mu.Unlock() + r.transactions[t.ID] = t +} + +func (r *MoMoRepository) ListTransactions(phone string) []models.MoMoTransaction { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.MoMoTransaction + for _, t := range r.transactions { + if phone == "" || t.Phone == phone { result = append(result, *t) } + } + return result +} + +func (r *MoMoRepository) GetOrCreateWallet(customerID, phone, currency string) *models.WalletBalance { + r.mu.Lock() + defer r.mu.Unlock() + if w, ok := r.wallets[phone]; ok { return w } + w := &models.WalletBalance{CustomerID: customerID, Phone: phone, Balance: 0, Currency: currency} + r.wallets[phone] = w + return w +} + +func (r *MoMoRepository) GetStats() map[string]interface{} { + r.mu.RLock() + defer r.mu.RUnlock() + totalVol := 0.0; totalFees := 0.0 + success, failed := 0, 0 + for _, t := range r.transactions { + totalVol += t.Amount; totalFees += t.Fee + if t.Status == "completed" { success++ } else if t.Status == "failed" { failed++ } + } + return map[string]interface{}{ + "total_transactions": len(r.transactions), "total_volume": totalVol, + "total_fees": totalFees, "success": success, "failed": failed, + "providers": len(r.providers), + } +} + +func init() { _ = time.Now } diff --git a/mobile-money-service/internal/service/service.go b/mobile-money-service/internal/service/service.go new file mode 100644 index 000000000..575de038c --- /dev/null +++ b/mobile-money-service/internal/service/service.go @@ -0,0 +1,92 @@ +package service + +import ( + "fmt" + "math" + "mobile-money-service/internal/models" + "mobile-money-service/internal/repository" + "time" +) + +type MoMoService struct { repo *repository.MoMoRepository } +func NewMoMoService(repo *repository.MoMoRepository) *MoMoService { return &MoMoService{repo: repo} } + +type PayRequest struct { + ProviderCode string `json:"provider_code"` + Phone string `json:"phone"` + Amount float64 `json:"amount"` + Currency string `json:"currency"` + PolicyID string `json:"policy_id,omitempty"` + Reference string `json:"reference,omitempty"` +} + +func (s *MoMoService) InitiatePayment(req PayRequest) (*models.MoMoTransaction, error) { + provider, err := s.repo.GetProvider(req.ProviderCode) + if err != nil { return nil, err } + if !provider.IsActive { return nil, fmt.Errorf("provider %s is not active", req.ProviderCode) } + if req.Amount < provider.MinAmount { return nil, fmt.Errorf("amount below minimum %.2f", provider.MinAmount) } + if req.Amount > provider.MaxAmount { return nil, fmt.Errorf("amount exceeds maximum %.2f", provider.MaxAmount) } + if req.Phone == "" { return nil, fmt.Errorf("phone number is required") } + + fee := math.Round((req.Amount*provider.FeePercent/100+provider.FeeFlat)*100) / 100 + + tx := &models.MoMoTransaction{ + ID: fmt.Sprintf("MOMO-%d", time.Now().UnixNano()%10000000), + Type: "collection", ProviderCode: req.ProviderCode, + Phone: req.Phone, Amount: req.Amount, Fee: fee, + NetAmount: req.Amount - fee, Currency: provider.Currency, + Reference: req.Reference, PolicyID: req.PolicyID, + Status: "pending", CreatedAt: time.Now(), + } + s.repo.CreateTransaction(tx) + + go s.processAsync(tx.ID) + + return tx, nil +} + +func (s *MoMoService) processAsync(id string) { + time.Sleep(2 * time.Second) + tx, _ := s.repo.GetTransaction(id) + if tx != nil { + now := time.Now() + tx.Status = "completed" + tx.CompletedAt = &now + tx.ProviderRef = fmt.Sprintf("REF-%d", time.Now().UnixNano()%1000000) + s.repo.UpdateTransaction(tx) + } +} + +type DisbursementRequest struct { + ProviderCode string `json:"provider_code"` + Phone string `json:"phone"` + Amount float64 `json:"amount"` + ClaimID string `json:"claim_id,omitempty"` + Reference string `json:"reference,omitempty"` +} + +func (s *MoMoService) Disburse(req DisbursementRequest) (*models.MoMoTransaction, error) { + provider, err := s.repo.GetProvider(req.ProviderCode) + if err != nil { return nil, err } + if req.Amount <= 0 { return nil, fmt.Errorf("amount must be positive") } + if req.Phone == "" { return nil, fmt.Errorf("phone number is required") } + + fee := math.Round((req.Amount*provider.FeePercent/100+provider.FeeFlat)*100) / 100 + + tx := &models.MoMoTransaction{ + ID: fmt.Sprintf("MOMO-%d", time.Now().UnixNano()%10000000), + Type: "disbursement", ProviderCode: req.ProviderCode, + Phone: req.Phone, Amount: req.Amount, Fee: fee, + NetAmount: req.Amount - fee, Currency: provider.Currency, + Reference: req.Reference, ClaimID: req.ClaimID, + Status: "pending", CreatedAt: time.Now(), + } + s.repo.CreateTransaction(tx) + go s.processAsync(tx.ID) + return tx, nil +} + +func (s *MoMoService) GetTransaction(id string) (*models.MoMoTransaction, error) { return s.repo.GetTransaction(id) } +func (s *MoMoService) ListTransactions(phone string) []models.MoMoTransaction { return s.repo.ListTransactions(phone) } +func (s *MoMoService) GetProviders(country string) []models.Provider { return s.repo.GetProviders(country) } +func (s *MoMoService) GetStats() map[string]interface{} { return s.repo.GetStats() } diff --git a/multi-country-regulatory/cmd/server/main.go b/multi-country-regulatory/cmd/server/main.go index 02f9f7b65..6a8c4e4c3 100644 --- a/multi-country-regulatory/cmd/server/main.go +++ b/multi-country-regulatory/cmd/server/main.go @@ -1,139 +1,24 @@ package main import ( - "encoding/json" - "fmt" - "log" - "net/http" - "os" + "fmt"; "log"; "net/http"; "os" + "multi-country-regulatory/internal/handlers" + "multi-country-regulatory/internal/repository" + "multi-country-regulatory/internal/service" ) func main() { port := os.Getenv("PORT") - if port == "" { - port = "8105" - } + if port == "" { port = "8105" } + repo := repository.NewRegulatoryRepository() + svc := service.NewRegulatoryService(repo) + h := handlers.NewHandler(svc) mux := http.NewServeMux() - mux.HandleFunc("/api/v1/regulatory/countries", handleCountries) - mux.HandleFunc("/api/v1/regulatory/requirements/", handleRequirements) - mux.HandleFunc("/api/v1/regulatory/compliance-check", handleComplianceCheck) - mux.HandleFunc("/api/v1/regulatory/licenses", handleLicenses) + h.RegisterRoutes(mux) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"multi-country-regulatory"}`)) - }) - log.Printf("Multi-Country Regulatory starting on port %s", port) - if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { - log.Fatal(err) - } -} - -type CountryRegulation struct { - CountryCode string `json:"country_code"` - CountryName string `json:"country_name"` - Regulator string `json:"regulator"` - RegulatorURL string `json:"regulator_url"` - DataProtection string `json:"data_protection_law"` - KYCRequirements []string `json:"kyc_requirements"` - LicenseTypes []string `json:"license_types"` - CapitalReq string `json:"minimum_capital_requirement"` - TaxRates map[string]float64 `json:"tax_rates"` - Currency string `json:"currency"` - MobileMoneyRegs string `json:"mobile_money_regulations"` - Status string `json:"status"` // active, planned, research -} - -func handleCountries(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "countries": []CountryRegulation{ - { - CountryCode: "NG", CountryName: "Nigeria", Regulator: "NAICOM", - RegulatorURL: "https://naicom.gov.ng", - DataProtection: "NDPR (Nigeria Data Protection Regulation)", - KYCRequirements: []string{"BVN", "NIN", "Driver's License", "Voter's Card", "International Passport"}, - LicenseTypes: []string{"Life Insurance", "General Insurance", "Composite", "Microinsurance", "Takaful"}, - CapitalReq: "NGN 3B (Life), NGN 3B (General)", - TaxRates: map[string]float64{"vat": 0.075, "stamp_duty": 0.0005, "naicom_levy": 0.01}, - Currency: "NGN", MobileMoneyRegs: "CBN Mobile Money Guidelines 2022", - Status: "active", - }, - { - CountryCode: "KE", CountryName: "Kenya", Regulator: "IRA Kenya", - RegulatorURL: "https://ira.go.ke", - DataProtection: "Kenya Data Protection Act 2019", - KYCRequirements: []string{"National ID", "KRA PIN", "Passport"}, - LicenseTypes: []string{"Life", "General", "Composite", "Micro"}, - CapitalReq: "KES 600M (Life), KES 300M (General)", - TaxRates: map[string]float64{"vat": 0.16, "excise_duty": 0.20}, - Currency: "KES", MobileMoneyRegs: "M-Pesa regulated by CBK", - Status: "planned", - }, - { - CountryCode: "GH", CountryName: "Ghana", Regulator: "NIC Ghana", - RegulatorURL: "https://nicgh.org", - DataProtection: "Ghana Data Protection Act 2012", - KYCRequirements: []string{"Ghana Card", "Voter's ID", "Passport", "SSNIT"}, - LicenseTypes: []string{"Life", "Non-Life", "Composite", "Micro"}, - CapitalReq: "GHS 50M (Life), GHS 25M (Non-Life)", - TaxRates: map[string]float64{"nhil": 0.025, "getfund": 0.025, "vat": 0.15}, - Currency: "GHS", MobileMoneyRegs: "E-Money Issuer License (BoG)", - Status: "planned", - }, - { - CountryCode: "ZA", CountryName: "South Africa", Regulator: "FSCA / PA", - RegulatorURL: "https://fsca.co.za", - DataProtection: "POPIA (Protection of Personal Information Act)", - KYCRequirements: []string{"SA ID Number", "Passport", "Proof of Address"}, - LicenseTypes: []string{"Long-term (Life)", "Short-term (General)", "Microinsurance"}, - CapitalReq: "ZAR 10M+ (risk-based capital)", - TaxRates: map[string]float64{"vat": 0.15, "policy_levy": 0.001}, - Currency: "ZAR", MobileMoneyRegs: "FIC Act, SARB fintech sandbox", - Status: "research", - }, - }, - }) -} - -func handleRequirements(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "country": "NG", - "requirements": []string{ - "Annual statutory returns to NAICOM", - "Quarterly financial statements", - "Risk-based capital adequacy compliance", - "NDPR data protection compliance", - "Anti-money laundering (AML/CFT) compliance", - "Motor insurance certificates via NMID", - "Group life compliance (Pension Reform Act)", - "Consumer protection guidelines", - }, - }) -} - -func handleComplianceCheck(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "country": "NG", - "checks": []map[string]interface{}{ - {"requirement": "NAICOM License", "status": "compliant", "expires": "2027-03-31"}, - {"requirement": "NDPR Registration", "status": "compliant", "reference": "NDPR/2026/001"}, - {"requirement": "Capital Adequacy", "status": "compliant", "ratio": 1.85}, - {"requirement": "AML/CFT Program", "status": "compliant", "last_audit": "2026-01-15"}, - {"requirement": "NMID Integration", "status": "compliant", "certificates_issued": 15420}, - {"requirement": "Consumer Complaints Resolution", "status": "compliant", "avg_resolution_days": 3}, - }, - "overall_status": "fully_compliant", - }) -} - -func handleLicenses(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "licenses": []map[string]interface{}{ - {"country": "NG", "type": "Composite", "status": "active", "number": "NAICOM/LIC/2024/001", "expires": "2027-03-31"}, - {"country": "NG", "type": "Microinsurance", "status": "active", "number": "NAICOM/MIC/2025/001", "expires": "2027-12-31"}, - }, + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"healthy","service":"multi-country-regulatory","version":"2.0.0"}`)) }) + log.Printf("Multi-Country Regulatory Service v2.0 starting on port %s", port) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), mux)) } diff --git a/multi-country-regulatory/go.mod b/multi-country-regulatory/go.mod index a752d7cac..fcc0894a7 100644 --- a/multi-country-regulatory/go.mod +++ b/multi-country-regulatory/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/multi-country-regulatory +module multi-country-regulatory go 1.22.0 diff --git a/multi-country-regulatory/internal/handlers/handlers.go b/multi-country-regulatory/internal/handlers/handlers.go new file mode 100644 index 000000000..acddd4279 --- /dev/null +++ b/multi-country-regulatory/internal/handlers/handlers.go @@ -0,0 +1,48 @@ +package handlers + +import ( + "encoding/json" + "multi-country-regulatory/internal/service" + "net/http" + "strings" +) + +type Handler struct { svc *service.RegulatoryService } +func NewHandler(svc *service.RegulatoryService) *Handler { return &Handler{svc: svc} } + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/regulatory/countries", h.GetCountries) + mux.HandleFunc("/api/v1/regulatory/country/", h.GetCountry) + mux.HandleFunc("/api/v1/regulatory/check", h.RunCheck) + mux.HandleFunc("/api/v1/regulatory/checks", h.GetChecks) + mux.HandleFunc("/api/v1/regulatory/reports", h.GetReports) + mux.HandleFunc("/api/v1/regulatory/stats", h.GetStats) +} + +func rj(w http.ResponseWriter, s int, d interface{}) { w.Header().Set("Content-Type","application/json"); w.WriteHeader(s); json.NewEncoder(w).Encode(d) } +func re(w http.ResponseWriter, s int, m string) { rj(w, s, map[string]string{"error": m}) } + +func (h *Handler) GetCountries(w http.ResponseWriter, r *http.Request) { rj(w, 200, map[string]interface{}{"countries": h.svc.GetCountries()}) } +func (h *Handler) GetCountry(w http.ResponseWriter, r *http.Request) { + code := strings.TrimPrefix(r.URL.Path, "/api/v1/regulatory/country/") + c, err := h.svc.GetCountry(code) + if err != nil { re(w, 404, err.Error()); return } + rj(w, 200, c) +} +func (h *Handler) RunCheck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { re(w, 405, "Method not allowed"); return } + var req service.CheckRequest + json.NewDecoder(r.Body).Decode(&req) + c, err := h.svc.RunComplianceCheck(req) + if err != nil { re(w, 400, err.Error()); return } + rj(w, 200, c) +} +func (h *Handler) GetChecks(w http.ResponseWriter, r *http.Request) { + tid := r.URL.Query().Get("tenant_id"); country := r.URL.Query().Get("country") + rj(w, 200, map[string]interface{}{"checks": h.svc.GetChecks(tid, country)}) +} +func (h *Handler) GetReports(w http.ResponseWriter, r *http.Request) { + tid := r.URL.Query().Get("tenant_id") + rj(w, 200, map[string]interface{}{"reports": h.svc.GetReports(tid)}) +} +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { rj(w, 200, h.svc.GetStats()) } diff --git a/multi-country-regulatory/internal/models/regulatory.go b/multi-country-regulatory/internal/models/regulatory.go new file mode 100644 index 000000000..17316001f --- /dev/null +++ b/multi-country-regulatory/internal/models/regulatory.go @@ -0,0 +1,47 @@ +package models + +import "time" + +type Country struct { + Code string `json:"code"` + Name string `json:"name"` + Regulator string `json:"regulator"` + Currency string `json:"currency"` + MinCapital float64 `json:"min_capital_requirement"` + LicenseTypes []string `json:"license_types"` + ReportingFreq string `json:"reporting_frequency"` + DataResidency bool `json:"data_residency_required"` + KYCLevel string `json:"kyc_level"` + TaxRate float64 `json:"insurance_tax_rate"` + IsActive bool `json:"is_active"` +} + +type ComplianceCheck struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Country string `json:"country"` + Category string `json:"category"` + Status string `json:"status"` + Score int `json:"score"` + MaxScore int `json:"max_score"` + Findings []Finding `json:"findings"` + CheckedAt time.Time `json:"checked_at"` +} + +type Finding struct { + Rule string `json:"rule"` + Status string `json:"status"` + Severity string `json:"severity"` + Details string `json:"details"` +} + +type RegulatoryReport struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Country string `json:"country"` + Type string `json:"type"` + Period string `json:"period"` + Status string `json:"status"` + DueDate time.Time `json:"due_date"` + FiledAt *time.Time `json:"filed_at,omitempty"` +} diff --git a/multi-country-regulatory/internal/repository/repository.go b/multi-country-regulatory/internal/repository/repository.go new file mode 100644 index 000000000..e6308d82c --- /dev/null +++ b/multi-country-regulatory/internal/repository/repository.go @@ -0,0 +1,103 @@ +package repository + +import ( + "fmt" + "multi-country-regulatory/internal/models" + "sync" + "time" +) + +type RegulatoryRepository struct { + mu sync.RWMutex + countries map[string]models.Country + checks []models.ComplianceCheck + reports []models.RegulatoryReport +} + +func NewRegulatoryRepository() *RegulatoryRepository { + repo := &RegulatoryRepository{ + countries: make(map[string]models.Country), + } + repo.seedCountries() + return repo +} + +func (r *RegulatoryRepository) seedCountries() { + countries := []models.Country{ + {Code: "NG", Name: "Nigeria", Regulator: "NAICOM", Currency: "NGN", MinCapital: 3000000000, LicenseTypes: []string{"life", "non-life", "composite", "micro", "takaful"}, ReportingFreq: "quarterly", DataResidency: true, KYCLevel: "enhanced", TaxRate: 0.05, IsActive: true}, + {Code: "KE", Name: "Kenya", Regulator: "IRA Kenya", Currency: "KES", MinCapital: 600000000, LicenseTypes: []string{"life", "general", "composite", "micro"}, ReportingFreq: "quarterly", DataResidency: true, KYCLevel: "standard", TaxRate: 0.045, IsActive: true}, + {Code: "GH", Name: "Ghana", Regulator: "NIC Ghana", Currency: "GHS", MinCapital: 50000000, LicenseTypes: []string{"life", "non-life", "reinsurance"}, ReportingFreq: "quarterly", DataResidency: false, KYCLevel: "standard", TaxRate: 0.06, IsActive: true}, + {Code: "ZA", Name: "South Africa", Regulator: "FSCA/PA", Currency: "ZAR", MinCapital: 10000000, LicenseTypes: []string{"life", "non-life", "composite", "micro", "cell_captive"}, ReportingFreq: "monthly", DataResidency: true, KYCLevel: "enhanced", TaxRate: 0.0, IsActive: true}, + {Code: "EG", Name: "Egypt", Regulator: "FRA Egypt", Currency: "EGP", MinCapital: 150000000, LicenseTypes: []string{"life", "property", "medical"}, ReportingFreq: "quarterly", DataResidency: true, KYCLevel: "enhanced", TaxRate: 0.10, IsActive: true}, + {Code: "RW", Name: "Rwanda", Regulator: "BNR", Currency: "RWF", MinCapital: 5000000000, LicenseTypes: []string{"life", "general", "micro"}, ReportingFreq: "quarterly", DataResidency: false, KYCLevel: "standard", TaxRate: 0.05, IsActive: true}, + } + for _, c := range countries { + r.countries[c.Code] = c + } +} + +func (r *RegulatoryRepository) GetCountries() []models.Country { + var result []models.Country + for _, c := range r.countries { + if c.IsActive { result = append(result, c) } + } + return result +} + +func (r *RegulatoryRepository) GetCountry(code string) (*models.Country, error) { + c, ok := r.countries[code] + if !ok { return nil, fmt.Errorf("country %s not found", code) } + return &c, nil +} + +func (r *RegulatoryRepository) AddCheck(c models.ComplianceCheck) { + r.mu.Lock() + defer r.mu.Unlock() + r.checks = append(r.checks, c) +} + +func (r *RegulatoryRepository) GetChecks(tenantID, country string) []models.ComplianceCheck { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.ComplianceCheck + for _, c := range r.checks { + if (tenantID == "" || c.TenantID == tenantID) && (country == "" || c.Country == country) { + result = append(result, c) + } + } + return result +} + +func (r *RegulatoryRepository) AddReport(rr models.RegulatoryReport) { + r.mu.Lock() + defer r.mu.Unlock() + r.reports = append(r.reports, rr) +} + +func (r *RegulatoryRepository) GetReports(tenantID string) []models.RegulatoryReport { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.RegulatoryReport + for _, rr := range r.reports { + if tenantID == "" || rr.TenantID == tenantID { + result = append(result, rr) + } + } + return result +} + +func (r *RegulatoryRepository) GetStats() map[string]interface{} { + r.mu.RLock() + defer r.mu.RUnlock() + passed, failed := 0, 0 + for _, c := range r.checks { + if c.Status == "compliant" { passed++ } else { failed++ } + } + return map[string]interface{}{ + "countries": len(r.countries), "total_checks": len(r.checks), + "compliant": passed, "non_compliant": failed, + "pending_reports": func() int { c := 0; for _, rr := range r.reports { if rr.Status == "pending" { c++ } }; return c }(), + } +} + +func init() { _ = time.Now } diff --git a/multi-country-regulatory/internal/service/service.go b/multi-country-regulatory/internal/service/service.go new file mode 100644 index 000000000..cea573379 --- /dev/null +++ b/multi-country-regulatory/internal/service/service.go @@ -0,0 +1,55 @@ +package service + +import ( + "fmt" + "multi-country-regulatory/internal/models" + "multi-country-regulatory/internal/repository" + "time" +) + +type RegulatoryService struct { repo *repository.RegulatoryRepository } +func NewRegulatoryService(repo *repository.RegulatoryRepository) *RegulatoryService { return &RegulatoryService{repo: repo} } + +type CheckRequest struct { + TenantID string `json:"tenant_id"` + Country string `json:"country"` +} + +func (s *RegulatoryService) RunComplianceCheck(req CheckRequest) (*models.ComplianceCheck, error) { + country, err := s.repo.GetCountry(req.Country) + if err != nil { return nil, err } + + findings := []models.Finding{ + {Rule: "Capital Adequacy", Status: "pass", Severity: "critical", Details: fmt.Sprintf("Meets minimum capital requirement of %s %.0f", country.Currency, country.MinCapital)}, + {Rule: "License Validity", Status: "pass", Severity: "critical", Details: "Operating license is valid and current"}, + {Rule: "KYC Compliance", Status: "pass", Severity: "high", Details: fmt.Sprintf("%s level KYC verification implemented", country.KYCLevel)}, + {Rule: "Data Residency", Status: "pass", Severity: "high", Details: func() string { if country.DataResidency { return "Data stored within country borders" }; return "No data residency requirement" }()}, + {Rule: "Regulatory Reporting", Status: "pass", Severity: "medium", Details: fmt.Sprintf("%s reporting schedule maintained", country.ReportingFreq)}, + {Rule: "Tax Compliance", Status: "pass", Severity: "high", Details: fmt.Sprintf("Insurance tax rate of %.1f%% applied", country.TaxRate*100)}, + {Rule: "Consumer Protection", Status: "pass", Severity: "medium", Details: "Complaint handling and disclosure requirements met"}, + {Rule: "AML/CFT Controls", Status: "pass", Severity: "critical", Details: "Anti-money laundering controls in place"}, + } + + score := 0 + for _, f := range findings { + if f.Status == "pass" { score += 10 } + } + + status := "compliant" + if score < 60 { status = "non_compliant" } else if score < 80 { status = "partial" } + + check := models.ComplianceCheck{ + ID: fmt.Sprintf("CHK-%d", time.Now().UnixNano()%10000000), + TenantID: req.TenantID, Country: req.Country, Category: "full_audit", + Status: status, Score: score, MaxScore: len(findings) * 10, + Findings: findings, CheckedAt: time.Now(), + } + s.repo.AddCheck(check) + return &check, nil +} + +func (s *RegulatoryService) GetCountries() []models.Country { return s.repo.GetCountries() } +func (s *RegulatoryService) GetCountry(code string) (*models.Country, error) { return s.repo.GetCountry(code) } +func (s *RegulatoryService) GetChecks(tenantID, country string) []models.ComplianceCheck { return s.repo.GetChecks(tenantID, country) } +func (s *RegulatoryService) GetReports(tenantID string) []models.RegulatoryReport { return s.repo.GetReports(tenantID) } +func (s *RegulatoryService) GetStats() map[string]interface{} { return s.repo.GetStats() } diff --git a/multi-currency-service/cmd/server/main.go b/multi-currency-service/cmd/server/main.go index 4081b9bf7..49cc14b70 100644 --- a/multi-currency-service/cmd/server/main.go +++ b/multi-currency-service/cmd/server/main.go @@ -1,12 +1,14 @@ package main import ( - "encoding/json" "fmt" "log" "net/http" "os" - "time" + + "multi-currency-service/internal/handlers" + "multi-currency-service/internal/repository" + "multi-currency-service/internal/service" ) func main() { @@ -14,103 +16,22 @@ func main() { if port == "" { port = "8102" } + + repo := repository.NewCurrencyRepository() + svc := service.NewCurrencyService(repo) + handler := handlers.NewHandler(svc) + mux := http.NewServeMux() - mux.HandleFunc("/api/v1/currency/rates", handleRates) - mux.HandleFunc("/api/v1/currency/convert", handleConvert) - mux.HandleFunc("/api/v1/currency/supported", handleSupported) - mux.HandleFunc("/api/v1/currency/settlement", handleSettlement) + handler.RegisterRoutes(mux) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"multi-currency-service"}`)) + w.Write([]byte(`{"status":"healthy","service":"multi-currency-service","version":"2.0.0"}`)) }) - log.Printf("Multi-Currency Service starting on port %s", port) + + log.Printf("Multi-Currency Service v2.0 starting on port %s", port) if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { log.Fatal(err) } } - -type ExchangeRate struct { - Base string `json:"base"` - Target string `json:"target"` - Rate float64 `json:"rate"` - BuyRate float64 `json:"buy_rate"` - SellRate float64 `json:"sell_rate"` - UpdatedAt time.Time `json:"updated_at"` -} - -func handleRates(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "base": "NGN", - "updated_at": time.Now().Format(time.RFC3339), - "rates": []ExchangeRate{ - {Base: "NGN", Target: "KES", Rate: 0.088, BuyRate: 0.086, SellRate: 0.090, UpdatedAt: time.Now()}, - {Base: "NGN", Target: "GHS", Rate: 0.0088, BuyRate: 0.0086, SellRate: 0.0090, UpdatedAt: time.Now()}, - {Base: "NGN", Target: "ZAR", Rate: 0.012, BuyRate: 0.0118, SellRate: 0.0122, UpdatedAt: time.Now()}, - {Base: "NGN", Target: "XOF", Rate: 0.40, BuyRate: 0.39, SellRate: 0.41, UpdatedAt: time.Now()}, - {Base: "NGN", Target: "USD", Rate: 0.00065, BuyRate: 0.00063, SellRate: 0.00067, UpdatedAt: time.Now()}, - {Base: "NGN", Target: "GBP", Rate: 0.00052, BuyRate: 0.00050, SellRate: 0.00054, UpdatedAt: time.Now()}, - }, - }) -} - -func handleConvert(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { - From string `json:"from"` - To string `json:"to"` - Amount float64 `json:"amount"` - } - json.NewDecoder(r.Body).Decode(&req) - - // Simplified conversion - rate := 0.088 // NGN to KES default - convertedAmount := req.Amount * rate - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "from": req.From, - "to": req.To, - "original_amount": req.Amount, - "converted_amount": convertedAmount, - "rate": rate, - "fee": req.Amount * 0.005, - "total_debit": req.Amount * 1.005, - }) -} - -func handleSupported(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "currencies": []map[string]string{ - {"code": "NGN", "name": "Nigerian Naira", "country": "Nigeria", "symbol": "\u20a6"}, - {"code": "KES", "name": "Kenyan Shilling", "country": "Kenya", "symbol": "KSh"}, - {"code": "GHS", "name": "Ghanaian Cedi", "country": "Ghana", "symbol": "GH\u20b5"}, - {"code": "ZAR", "name": "South African Rand", "country": "South Africa", "symbol": "R"}, - {"code": "XOF", "name": "West African CFA Franc", "country": "WAEMU", "symbol": "CFA"}, - {"code": "XAF", "name": "Central African CFA Franc", "country": "CEMAC", "symbol": "FCFA"}, - {"code": "TZS", "name": "Tanzanian Shilling", "country": "Tanzania", "symbol": "TSh"}, - {"code": "UGX", "name": "Ugandan Shilling", "country": "Uganda", "symbol": "USh"}, - {"code": "RWF", "name": "Rwandan Franc", "country": "Rwanda", "symbol": "FRw"}, - {"code": "USD", "name": "US Dollar", "country": "International", "symbol": "$"}, - {"code": "GBP", "name": "British Pound", "country": "International", "symbol": "\u00a3"}, - }, - }) -} - -func handleSettlement(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]interface{}{ - "settlement_id": fmt.Sprintf("STL-%d", time.Now().UnixNano()%1000000), - "status": "initiated", - "message": "Cross-border settlement initiated", - }) -} diff --git a/multi-currency-service/go.mod b/multi-currency-service/go.mod index d9243b6f7..dbd86d7f6 100644 --- a/multi-currency-service/go.mod +++ b/multi-currency-service/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/multi-currency-service +module multi-currency-service go 1.22.0 diff --git a/multi-currency-service/internal/handlers/handlers.go b/multi-currency-service/internal/handlers/handlers.go new file mode 100644 index 000000000..b03fe9c22 --- /dev/null +++ b/multi-currency-service/internal/handlers/handlers.go @@ -0,0 +1,92 @@ +package handlers + +import ( + "encoding/json" + "multi-currency-service/internal/service" + "net/http" + "strings" +) + +type Handler struct { + svc *service.CurrencyService +} + +func NewHandler(svc *service.CurrencyService) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/currency/rates", h.GetRates) + mux.HandleFunc("/api/v1/currency/convert", h.Convert) + mux.HandleFunc("/api/v1/currency/pairs", h.GetPairs) + mux.HandleFunc("/api/v1/currency/list", h.GetCurrencies) + mux.HandleFunc("/api/v1/currency/rate/", h.GetSingleRate) + mux.HandleFunc("/api/v1/currency/conversion/", h.GetConversion) +} + +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func respondError(w http.ResponseWriter, status int, msg string) { + respondJSON(w, status, map[string]string{"error": msg}) +} + +func (h *Handler) GetRates(w http.ResponseWriter, r *http.Request) { + base := r.URL.Query().Get("base") + rates := h.svc.GetAllRates(base) + respondJSON(w, http.StatusOK, map[string]interface{}{"base": base, "rates": rates, "count": len(rates)}) +} + +func (h *Handler) Convert(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + var req service.ConvertRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body") + return + } + conv, err := h.svc.Convert(req) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusCreated, conv) +} + +func (h *Handler) GetPairs(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, map[string]interface{}{"pairs": h.svc.GetPairs()}) +} + +func (h *Handler) GetCurrencies(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, map[string]interface{}{"currencies": h.svc.GetCurrencies()}) +} + +func (h *Handler) GetSingleRate(w http.ResponseWriter, r *http.Request) { + pair := strings.TrimPrefix(r.URL.Path, "/api/v1/currency/rate/") + parts := strings.Split(pair, "/") + if len(parts) != 2 { + respondError(w, http.StatusBadRequest, "Use /api/v1/currency/rate/{base}/{quote}") + return + } + rate, err := h.svc.GetRate(parts[0], parts[1]) + if err != nil { + respondError(w, http.StatusNotFound, err.Error()) + return + } + respondJSON(w, http.StatusOK, rate) +} + +func (h *Handler) GetConversion(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/currency/conversion/") + conv, err := h.svc.GetConversion(id) + if err != nil { + respondError(w, http.StatusNotFound, err.Error()) + return + } + respondJSON(w, http.StatusOK, conv) +} diff --git a/multi-currency-service/internal/models/currency.go b/multi-currency-service/internal/models/currency.go new file mode 100644 index 000000000..a445495ac --- /dev/null +++ b/multi-currency-service/internal/models/currency.go @@ -0,0 +1,48 @@ +package models + +import "time" + +type Currency struct { + Code string `json:"code"` + Name string `json:"name"` + Symbol string `json:"symbol"` + Country string `json:"country"` + IsActive bool `json:"is_active"` + Decimals int `json:"decimals"` +} + +type ExchangeRate struct { + ID string `json:"id"` + BaseCurrency string `json:"base_currency"` + QuoteCurrency string `json:"quote_currency"` + Rate float64 `json:"rate"` + Bid float64 `json:"bid"` + Ask float64 `json:"ask"` + Spread float64 `json:"spread"` + Source string `json:"source"` + ValidFrom time.Time `json:"valid_from"` + ValidTo time.Time `json:"valid_to"` +} + +type Conversion struct { + ID string `json:"id"` + FromCurrency string `json:"from_currency"` + ToCurrency string `json:"to_currency"` + OriginalAmount float64 `json:"original_amount"` + ConvertedAmount float64 `json:"converted_amount"` + Rate float64 `json:"rate"` + Fee float64 `json:"fee"` + NetAmount float64 `json:"net_amount"` + Purpose string `json:"purpose"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +type CurrencyPair struct { + Pair string `json:"pair"` + Rate float64 `json:"rate"` + Change float64 `json:"change_24h"` + High float64 `json:"high_24h"` + Low float64 `json:"low_24h"` + Volume float64 `json:"volume_24h"` +} diff --git a/multi-currency-service/internal/repository/repository.go b/multi-currency-service/internal/repository/repository.go new file mode 100644 index 000000000..1a3779356 --- /dev/null +++ b/multi-currency-service/internal/repository/repository.go @@ -0,0 +1,174 @@ +package repository + +import ( + "fmt" + "math" + "math/rand" + "multi-currency-service/internal/models" + "sync" + "time" +) + +type CurrencyRepository struct { + mu sync.RWMutex + currencies map[string]models.Currency + rates map[string]models.ExchangeRate + conversions map[string]*models.Conversion + baseRates map[string]float64 +} + +func NewCurrencyRepository() *CurrencyRepository { + repo := &CurrencyRepository{ + currencies: make(map[string]models.Currency), + rates: make(map[string]models.ExchangeRate), + conversions: make(map[string]*models.Conversion), + baseRates: map[string]float64{ + "NGN": 1.0, + "USD": 0.000645, + "EUR": 0.000593, + "GBP": 0.000514, + "GHS": 0.00786, + "KES": 0.0832, + "ZAR": 0.01176, + "XOF": 0.389, + "EGP": 0.0316, + "UGX": 2.38, + "TZS": 1.617, + "RWF": 0.876, + "ETB": 0.0782, + }, + } + repo.seedCurrencies() + repo.refreshRates() + return repo +} + +func (r *CurrencyRepository) seedCurrencies() { + currencies := []models.Currency{ + {Code: "NGN", Name: "Nigerian Naira", Symbol: "₦", Country: "Nigeria", IsActive: true, Decimals: 2}, + {Code: "USD", Name: "US Dollar", Symbol: "$", Country: "United States", IsActive: true, Decimals: 2}, + {Code: "EUR", Name: "Euro", Symbol: "€", Country: "Eurozone", IsActive: true, Decimals: 2}, + {Code: "GBP", Name: "British Pound", Symbol: "£", Country: "United Kingdom", IsActive: true, Decimals: 2}, + {Code: "GHS", Name: "Ghana Cedi", Symbol: "GH₵", Country: "Ghana", IsActive: true, Decimals: 2}, + {Code: "KES", Name: "Kenyan Shilling", Symbol: "KSh", Country: "Kenya", IsActive: true, Decimals: 2}, + {Code: "ZAR", Name: "South African Rand", Symbol: "R", Country: "South Africa", IsActive: true, Decimals: 2}, + {Code: "XOF", Name: "West African CFA Franc", Symbol: "CFA", Country: "WAEMU", IsActive: true, Decimals: 0}, + {Code: "EGP", Name: "Egyptian Pound", Symbol: "E£", Country: "Egypt", IsActive: true, Decimals: 2}, + {Code: "UGX", Name: "Ugandan Shilling", Symbol: "USh", Country: "Uganda", IsActive: true, Decimals: 0}, + {Code: "TZS", Name: "Tanzanian Shilling", Symbol: "TSh", Country: "Tanzania", IsActive: true, Decimals: 0}, + {Code: "RWF", Name: "Rwandan Franc", Symbol: "RF", Country: "Rwanda", IsActive: true, Decimals: 0}, + {Code: "ETB", Name: "Ethiopian Birr", Symbol: "Br", Country: "Ethiopia", IsActive: true, Decimals: 2}, + } + for _, c := range currencies { + r.currencies[c.Code] = c + } +} + +func (r *CurrencyRepository) refreshRates() { + r.mu.Lock() + defer r.mu.Unlock() + now := time.Now() + validTo := now.Add(5 * time.Minute) + for base, baseRate := range r.baseRates { + for quote, quoteRate := range r.baseRates { + if base == quote { + continue + } + rate := quoteRate / baseRate + jitter := 1.0 + (rand.Float64()-0.5)*0.002 + rate *= jitter + spread := rate * 0.005 + key := base + "/" + quote + r.rates[key] = models.ExchangeRate{ + ID: fmt.Sprintf("RATE-%s-%d", key, now.Unix()), + BaseCurrency: base, + QuoteCurrency: quote, + Rate: math.Round(rate*1000000) / 1000000, + Bid: math.Round((rate-spread/2)*1000000) / 1000000, + Ask: math.Round((rate+spread/2)*1000000) / 1000000, + Spread: math.Round(spread*1000000) / 1000000, + Source: "CBN/Reuters", + ValidFrom: now, + ValidTo: validTo, + } + } + } +} + +func (r *CurrencyRepository) GetCurrencies() []models.Currency { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.Currency + for _, c := range r.currencies { + if c.IsActive { + result = append(result, c) + } + } + return result +} + +func (r *CurrencyRepository) GetRate(base, quote string) (*models.ExchangeRate, error) { + r.mu.RLock() + defer r.mu.RUnlock() + key := base + "/" + quote + rate, ok := r.rates[key] + if !ok { + return nil, fmt.Errorf("rate not found for %s", key) + } + if time.Now().After(rate.ValidTo) { + r.mu.RUnlock() + r.refreshRates() + r.mu.RLock() + rate = r.rates[key] + } + return &rate, nil +} + +func (r *CurrencyRepository) GetAllRates(base string) []models.ExchangeRate { + r.mu.RLock() + defer r.mu.RUnlock() + var rates []models.ExchangeRate + for _, rate := range r.rates { + if rate.BaseCurrency == base { + rates = append(rates, rate) + } + } + return rates +} + +func (r *CurrencyRepository) SaveConversion(c *models.Conversion) { + r.mu.Lock() + defer r.mu.Unlock() + r.conversions[c.ID] = c +} + +func (r *CurrencyRepository) GetConversion(id string) (*models.Conversion, error) { + r.mu.RLock() + defer r.mu.RUnlock() + c, ok := r.conversions[id] + if !ok { + return nil, fmt.Errorf("conversion %s not found", id) + } + return c, nil +} + +func (r *CurrencyRepository) GetPairs() []models.CurrencyPair { + r.mu.RLock() + defer r.mu.RUnlock() + var pairs []models.CurrencyPair + majors := [][2]string{{"NGN", "USD"}, {"NGN", "GBP"}, {"NGN", "EUR"}, {"NGN", "GHS"}, {"NGN", "KES"}, {"NGN", "ZAR"}, {"USD", "NGN"}, {"GBP", "NGN"}} + for _, m := range majors { + key := m[0] + "/" + m[1] + if rate, ok := r.rates[key]; ok { + pairs = append(pairs, models.CurrencyPair{ + Pair: key, + Rate: rate.Rate, + Change: (rand.Float64() - 0.5) * 2.0, + High: rate.Rate * 1.01, + Low: rate.Rate * 0.99, + Volume: float64(rand.Intn(50000000)) + 10000000, + }) + } + } + return pairs +} diff --git a/multi-currency-service/internal/service/service.go b/multi-currency-service/internal/service/service.go new file mode 100644 index 000000000..b8597b525 --- /dev/null +++ b/multi-currency-service/internal/service/service.go @@ -0,0 +1,97 @@ +package service + +import ( + "fmt" + "math" + "multi-currency-service/internal/models" + "multi-currency-service/internal/repository" + "time" +) + +type CurrencyService struct { + repo *repository.CurrencyRepository +} + +func NewCurrencyService(repo *repository.CurrencyRepository) *CurrencyService { + return &CurrencyService{repo: repo} +} + +type ConvertRequest struct { + From string `json:"from_currency"` + To string `json:"to_currency"` + Amount float64 `json:"amount"` + Purpose string `json:"purpose"` +} + +func (s *CurrencyService) Convert(req ConvertRequest) (*models.Conversion, error) { + if req.Amount <= 0 { + return nil, fmt.Errorf("amount must be positive") + } + if req.From == req.To { + return nil, fmt.Errorf("from and to currencies must differ") + } + + rate, err := s.repo.GetRate(req.From, req.To) + if err != nil { + return nil, err + } + + feeRate := s.calculateFee(req.From, req.To, req.Amount) + fee := math.Round(req.Amount*feeRate*100) / 100 + convertedAmount := math.Round(req.Amount*rate.Ask*100) / 100 + netAmount := math.Round((convertedAmount-fee)*100) / 100 + + conv := &models.Conversion{ + ID: fmt.Sprintf("CNV-%d", time.Now().UnixNano()%10000000), + FromCurrency: req.From, + ToCurrency: req.To, + OriginalAmount: req.Amount, + ConvertedAmount: convertedAmount, + Rate: rate.Ask, + Fee: fee, + NetAmount: netAmount, + Purpose: req.Purpose, + Status: "completed", + CreatedAt: time.Now(), + } + s.repo.SaveConversion(conv) + return conv, nil +} + +func (s *CurrencyService) calculateFee(from, to string, amount float64) float64 { + africanCurrencies := map[string]bool{"NGN": true, "GHS": true, "KES": true, "ZAR": true, "XOF": true, "EGP": true, "UGX": true, "TZS": true, "RWF": true, "ETB": true} + bothAfrican := africanCurrencies[from] && africanCurrencies[to] + if bothAfrican { + if amount > 10000000 { + return 0.001 + } + return 0.005 + } + if amount > 50000000 { + return 0.002 + } + return 0.01 +} + +func (s *CurrencyService) GetCurrencies() []models.Currency { + return s.repo.GetCurrencies() +} + +func (s *CurrencyService) GetRate(base, quote string) (*models.ExchangeRate, error) { + return s.repo.GetRate(base, quote) +} + +func (s *CurrencyService) GetAllRates(base string) []models.ExchangeRate { + if base == "" { + base = "NGN" + } + return s.repo.GetAllRates(base) +} + +func (s *CurrencyService) GetPairs() []models.CurrencyPair { + return s.repo.GetPairs() +} + +func (s *CurrencyService) GetConversion(id string) (*models.Conversion, error) { + return s.repo.GetConversion(id) +} diff --git a/multi-language-service/cmd/server/main.go b/multi-language-service/cmd/server/main.go index 75508352d..0a99bb308 100644 --- a/multi-language-service/cmd/server/main.go +++ b/multi-language-service/cmd/server/main.go @@ -1,140 +1,27 @@ package main import ( - "encoding/json" "fmt" "log" "net/http" "os" - "strings" + "multi-language-service/internal/handlers" + "multi-language-service/internal/repository" + "multi-language-service/internal/service" ) func main() { port := os.Getenv("PORT") - if port == "" { - port = "8108" - } + if port == "" { port = "8108" } + repo := repository.NewI18nRepository() + svc := service.NewI18nService(repo) + h := handlers.NewHandler(svc) mux := http.NewServeMux() - mux.HandleFunc("/api/v1/i18n/translate", handleTranslate) - mux.HandleFunc("/api/v1/i18n/languages", handleLanguages) - mux.HandleFunc("/api/v1/i18n/templates/", handleTemplates) + h.RegisterRoutes(mux) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"multi-language-service"}`)) - }) - log.Printf("Multi-Language Service starting on port %s", port) - if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { - log.Fatal(err) - } -} - -var translations = map[string]map[string]string{ - "welcome": { - "en": "Welcome to NGApp Insurance", - "ha": "Barka da zuwa NGApp Inshora", - "yo": "Ẹ kaabo si NGApp Iṣeduro", - "ig": "Nnọọ na NGApp Mkpuchi", - "pcm": "Welcome to NGApp Insurance", - "fr": "Bienvenue chez NGApp Assurance", - "ar": "مرحبا بك في تأمين NGApp", - "sw": "Karibu NGApp Bima", - }, - "buy_insurance": { - "en": "Buy Insurance", "ha": "Sayi Inshora", "yo": "Ra Iṣeduro", - "ig": "Zụta Mkpuchi", "pcm": "Buy Insurance", "fr": "Acheter Assurance", - "ar": "شراء تأمين", "sw": "Nunua Bima", - }, - "file_claim": { - "en": "File a Claim", "ha": "Shigar da Ƙara", "yo": "Ṣe Ẹtọ", - "ig": "Tinye Arịrịọ", "pcm": "Make Claim", "fr": "Déposer Réclamation", - "ar": "تقديم مطالبة", "sw": "Wasilisha Madai", - }, - "policy_active": { - "en": "Your policy is active", "ha": "Siyasar ku tana aiki", - "yo": "Eto rẹ n ṣiṣẹ", "ig": "Iwu gị na-arụ ọrụ", - "pcm": "Your policy dey active", "fr": "Votre police est active", - "ar": "وثيقتك نشطة", "sw": "Sera yako iko hai", - }, - "claim_approved": { - "en": "Your claim has been approved", "ha": "An amince da karar ku", - "yo": "A ti fọwọsi ẹtọ rẹ", "ig": "A nabatara arịrịọ gị", - "pcm": "Dem don approve your claim", "fr": "Votre réclamation a été approuvée", - "ar": "تمت الموافقة على مطالبتك", "sw": "Madai yako yamekubaliwa", - }, - "payment_due": { - "en": "Your premium payment is due", "ha": "Lokacin biyan ku ya yi", - "yo": "Owo isanwo rẹ ti to", "ig": "Oge ịkwụ ụgwọ gị eruola", - "pcm": "Time don reach to pay", "fr": "Votre paiement est dû", - "ar": "موعد دفع القسط", "sw": "Malipo yako yamefikia", - }, -} - -func handleTranslate(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { - Key string `json:"key"` - Language string `json:"language"` - Text string `json:"text,omitempty"` - } - json.NewDecoder(r.Body).Decode(&req) - if req.Language == "" { - req.Language = "en" - } - - result := "" - if trans, ok := translations[req.Key]; ok { - if t, ok := trans[req.Language]; ok { - result = t - } else { - result = trans["en"] - } - } else { - result = req.Text - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "key": req.Key, - "language": req.Language, - "text": result, - }) -} - -func handleLanguages(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "languages": []map[string]string{ - {"code": "en", "name": "English", "native": "English", "region": "Pan-African", "direction": "ltr"}, - {"code": "ha", "name": "Hausa", "native": "Hausa", "region": "Northern Nigeria, Niger", "direction": "ltr"}, - {"code": "yo", "name": "Yoruba", "native": "Yorùbá", "region": "Southwest Nigeria", "direction": "ltr"}, - {"code": "ig", "name": "Igbo", "native": "Igbo", "region": "Southeast Nigeria", "direction": "ltr"}, - {"code": "pcm", "name": "Nigerian Pidgin", "native": "Naija", "region": "Pan-Nigeria", "direction": "ltr"}, - {"code": "fr", "name": "French", "native": "Français", "region": "Francophone Africa", "direction": "ltr"}, - {"code": "ar", "name": "Arabic", "native": "العربية", "region": "North/Northern Nigeria", "direction": "rtl"}, - {"code": "sw", "name": "Swahili", "native": "Kiswahili", "region": "East Africa", "direction": "ltr"}, - {"code": "am", "name": "Amharic", "native": "አማርኛ", "region": "Ethiopia", "direction": "ltr"}, - {"code": "zu", "name": "Zulu", "native": "isiZulu", "region": "South Africa", "direction": "ltr"}, - }, - }) -} - -func handleTemplates(w http.ResponseWriter, r *http.Request) { - parts := strings.Split(r.URL.Path, "/") - lang := "en" - if len(parts) > 5 { - lang = parts[5] - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "language": lang, - "templates": map[string]interface{}{ - "sms_payment_reminder": translations["payment_due"][lang], - "sms_claim_approved": translations["claim_approved"][lang], - "sms_policy_active": translations["policy_active"][lang], - "whatsapp_welcome": translations["welcome"][lang], - }, + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"healthy","service":"multi-language-service","version":"2.0.0"}`)) }) + log.Printf("Multi-Language Service v2.0 starting on port %s", port) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), mux)) } diff --git a/multi-language-service/go.mod b/multi-language-service/go.mod index c3e31d1cd..812f04f38 100644 --- a/multi-language-service/go.mod +++ b/multi-language-service/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/multi-language-service +module multi-language-service go 1.22.0 diff --git a/multi-language-service/internal/handlers/handlers.go b/multi-language-service/internal/handlers/handlers.go new file mode 100644 index 000000000..9ada5122e --- /dev/null +++ b/multi-language-service/internal/handlers/handlers.go @@ -0,0 +1,51 @@ +package handlers + +import ( + "encoding/json" + "multi-language-service/internal/models" + "multi-language-service/internal/service" + "net/http" + "strings" +) + +type Handler struct { svc *service.I18nService } +func NewHandler(svc *service.I18nService) *Handler { return &Handler{svc: svc} } + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/i18n/languages", h.GetLanguages) + mux.HandleFunc("/api/v1/i18n/bundle/", h.GetBundle) + mux.HandleFunc("/api/v1/i18n/translate", h.Translate) + mux.HandleFunc("/api/v1/i18n/translation", h.SetTranslation) + mux.HandleFunc("/api/v1/i18n/stats", h.GetStats) +} + +func rj(w http.ResponseWriter, s int, d interface{}) { w.Header().Set("Content-Type","application/json"); w.WriteHeader(s); json.NewEncoder(w).Encode(d) } +func re(w http.ResponseWriter, s int, m string) { rj(w, s, map[string]string{"error": m}) } + +func (h *Handler) GetLanguages(w http.ResponseWriter, r *http.Request) { + rj(w, 200, map[string]interface{}{"languages": h.svc.GetLanguages()}) +} + +func (h *Handler) GetBundle(w http.ResponseWriter, r *http.Request) { + lang := strings.TrimPrefix(r.URL.Path, "/api/v1/i18n/bundle/") + bundle := h.svc.GetBundle(lang) + if bundle == nil { re(w, 404, "language not found"); return } + rj(w, 200, bundle) +} + +func (h *Handler) Translate(w http.ResponseWriter, r *http.Request) { + key := r.URL.Query().Get("key"); lang := r.URL.Query().Get("lang") + if key == "" || lang == "" { re(w, 400, "key and lang required"); return } + text := h.svc.Translate(key, lang, nil) + rj(w, 200, map[string]string{"key": key, "language": lang, "text": text}) +} + +func (h *Handler) SetTranslation(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { re(w, 405, "Method not allowed"); return } + var t models.Translation + json.NewDecoder(r.Body).Decode(&t) + h.svc.SetTranslation(t) + rj(w, 200, map[string]string{"status": "saved"}) +} + +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { rj(w, 200, h.svc.GetStats()) } diff --git a/multi-language-service/internal/models/i18n.go b/multi-language-service/internal/models/i18n.go new file mode 100644 index 000000000..274ca07ae --- /dev/null +++ b/multi-language-service/internal/models/i18n.go @@ -0,0 +1,27 @@ +package models + +import "time" + +type Language struct { + Code string `json:"code"` + Name string `json:"name"` + NativeName string `json:"native_name"` + Direction string `json:"direction"` + IsActive bool `json:"is_active"` + Coverage int `json:"coverage_pct"` +} + +type Translation struct { + ID string `json:"id"` + Key string `json:"key"` + Language string `json:"language"` + Value string `json:"value"` + Context string `json:"context,omitempty"` + Verified bool `json:"verified"` +} + +type TranslationBundle struct { + Language string `json:"language"` + Translations map[string]string `json:"translations"` + UpdatedAt time.Time `json:"updated_at"` +} diff --git a/multi-language-service/internal/repository/repository.go b/multi-language-service/internal/repository/repository.go new file mode 100644 index 000000000..1b6551b8c --- /dev/null +++ b/multi-language-service/internal/repository/repository.go @@ -0,0 +1,126 @@ +package repository + +import ( + "fmt" + "multi-language-service/internal/models" + "sync" + "time" +) + +type I18nRepository struct { + mu sync.RWMutex + languages map[string]models.Language + translations map[string]map[string]models.Translation +} + +func NewI18nRepository() *I18nRepository { + repo := &I18nRepository{ + languages: make(map[string]models.Language), + translations: make(map[string]map[string]models.Translation), + } + repo.seedLanguages() + repo.seedTranslations() + return repo +} + +func (r *I18nRepository) seedLanguages() { + langs := []models.Language{ + {Code: "en", Name: "English", NativeName: "English", Direction: "ltr", IsActive: true, Coverage: 100}, + {Code: "yo", Name: "Yoruba", NativeName: "Èdè Yorùbá", Direction: "ltr", IsActive: true, Coverage: 85}, + {Code: "ha", Name: "Hausa", NativeName: "Harshen Hausa", Direction: "ltr", IsActive: true, Coverage: 85}, + {Code: "ig", Name: "Igbo", NativeName: "Asụsụ Igbo", Direction: "ltr", IsActive: true, Coverage: 80}, + {Code: "pcm", Name: "Nigerian Pidgin", NativeName: "Naija", Direction: "ltr", IsActive: true, Coverage: 90}, + {Code: "fr", Name: "French", NativeName: "Français", Direction: "ltr", IsActive: true, Coverage: 70}, + {Code: "ar", Name: "Arabic", NativeName: "العربية", Direction: "rtl", IsActive: true, Coverage: 40}, + {Code: "sw", Name: "Swahili", NativeName: "Kiswahili", Direction: "ltr", IsActive: true, Coverage: 55}, + {Code: "am", Name: "Amharic", NativeName: "አማርኛ", Direction: "ltr", IsActive: true, Coverage: 30}, + {Code: "zu", Name: "Zulu", NativeName: "isiZulu", Direction: "ltr", IsActive: true, Coverage: 25}, + } + for _, l := range langs { + r.languages[l.Code] = l + } +} + +func (r *I18nRepository) seedTranslations() { + keys := map[string]map[string]string{ + "app.title": {"en": "NGInsure - Insurance for Everyone", "yo": "NGInsure - Iṣeduro fun Gbogbo Eniyan", "ha": "NGInsure - Inshora don Kowa", "ig": "NGInsure - Inshọransị maka Onye Ọ Bụla", "pcm": "NGInsure - Insurance for Everybody"}, + "nav.dashboard": {"en": "Dashboard", "yo": "Dasibọọdu", "ha": "Dashboard", "ig": "Dashboard", "pcm": "Dashboard"}, + "nav.policies": {"en": "My Policies", "yo": "Àwọn Ìṣedúró Mi", "ha": "Inshorar Ni", "ig": "Ọrụ Inshọransị M", "pcm": "My Policies"}, + "nav.claims": {"en": "Claims", "yo": "Ẹ̀tọ́", "ha": "Da'awar", "ig": "Arịrịọ", "pcm": "Claims"}, + "nav.payments": {"en": "Payments", "yo": "Àwọn Ìsanwó", "ha": "Biyan Kuɗi", "ig": "Ịkwụ Ụgwọ", "pcm": "Payments"}, + "action.pay_now": {"en": "Pay Now", "yo": "San Báyìí", "ha": "Biya Yanzu", "ig": "Kwụọ Ụgwọ Ugbu a", "pcm": "Pay Now"}, + "action.file_claim": {"en": "File a Claim", "yo": "Fi Ẹ̀tọ́ Sílẹ̀", "ha": "Gabatar Da Da'awa", "ig": "Tinye Arịrịọ", "pcm": "File Claim"}, + "action.renew": {"en": "Renew Policy", "yo": "Ṣe Àtúnṣe Ìṣedúró", "ha": "Sabunta Inshora", "ig": "Megharịa Ọrụ", "pcm": "Renew Policy"}, + "status.active": {"en": "Active", "yo": "Ṣiṣẹ́", "ha": "Mai Aiki", "ig": "Na-arụ Ọrụ", "pcm": "Active"}, + "status.expired": {"en": "Expired", "yo": "Ti Parẹ́", "ha": "Ya Ƙare", "ig": "Agwụla", "pcm": "Don Expire"}, + "msg.welcome": {"en": "Welcome back, {{name}}!", "yo": "Ẹ kú àbọ̀, {{name}}!", "ha": "Barka da dawowa, {{name}}!", "ig": "Nnọọ, {{name}}!", "pcm": "Welcome back, {{name}}!"}, + "msg.premium_due": {"en": "Your premium of {{amount}} is due on {{date}}", "yo": "Owó ìṣedúró rẹ ti {{amount}} gbọdọ̀ san ní {{date}}", "ha": "Kudin inshorar ka na {{amount}} ya kamata a biya a {{date}}", "ig": "Ụgwọ premium gị nke {{amount}} kwesịrị ịkwụ na {{date}}", "pcm": "Your premium of {{amount}} suppose pay on {{date}}"}, + } + for key, translations := range keys { + for lang, value := range translations { + if r.translations[lang] == nil { + r.translations[lang] = make(map[string]models.Translation) + } + r.translations[lang][key] = models.Translation{ + ID: fmt.Sprintf("T-%s-%s", lang, key), Key: key, Language: lang, Value: value, Verified: true, + } + } + } +} + +func (r *I18nRepository) GetLanguages() []models.Language { + var result []models.Language + for _, l := range r.languages { + if l.IsActive { result = append(result, l) } + } + return result +} + +func (r *I18nRepository) GetBundle(lang string) *models.TranslationBundle { + r.mu.RLock() + defer r.mu.RUnlock() + translations, ok := r.translations[lang] + if !ok { return nil } + bundle := &models.TranslationBundle{ + Language: lang, Translations: make(map[string]string), UpdatedAt: time.Now(), + } + for k, v := range translations { + bundle.Translations[k] = v.Value + } + return bundle +} + +func (r *I18nRepository) Translate(key, lang string) string { + r.mu.RLock() + defer r.mu.RUnlock() + if translations, ok := r.translations[lang]; ok { + if t, ok := translations[key]; ok { return t.Value } + } + if translations, ok := r.translations["en"]; ok { + if t, ok := translations[key]; ok { return t.Value } + } + return key +} + +func (r *I18nRepository) SetTranslation(t models.Translation) { + r.mu.Lock() + defer r.mu.Unlock() + if r.translations[t.Language] == nil { + r.translations[t.Language] = make(map[string]models.Translation) + } + r.translations[t.Language][t.Key] = t +} + +func (r *I18nRepository) GetStats() map[string]interface{} { + r.mu.RLock() + defer r.mu.RUnlock() + byLang := map[string]int{} + total := 0 + for lang, translations := range r.translations { + byLang[lang] = len(translations) + total += len(translations) + } + return map[string]interface{}{ + "languages": len(r.languages), "total_translations": total, "by_language": byLang, + } +} diff --git a/multi-language-service/internal/service/service.go b/multi-language-service/internal/service/service.go new file mode 100644 index 000000000..8bc8bab75 --- /dev/null +++ b/multi-language-service/internal/service/service.go @@ -0,0 +1,35 @@ +package service + +import ( + "multi-language-service/internal/models" + "multi-language-service/internal/repository" + "strings" +) + +type I18nService struct { + repo *repository.I18nRepository +} + +func NewI18nService(repo *repository.I18nRepository) *I18nService { + return &I18nService{repo: repo} +} + +func (s *I18nService) GetLanguages() []models.Language { return s.repo.GetLanguages() } + +func (s *I18nService) GetBundle(lang string) *models.TranslationBundle { + return s.repo.GetBundle(lang) +} + +func (s *I18nService) Translate(key, lang string, vars map[string]string) string { + text := s.repo.Translate(key, lang) + for k, v := range vars { + text = strings.ReplaceAll(text, "{{"+k+"}}", v) + } + return text +} + +func (s *I18nService) SetTranslation(t models.Translation) { + s.repo.SetTranslation(t) +} + +func (s *I18nService) GetStats() map[string]interface{} { return s.repo.GetStats() } diff --git a/multi-tenant-platform/cmd/server/main.go b/multi-tenant-platform/cmd/server/main.go index e21b5a5a2..8f13c6303 100644 --- a/multi-tenant-platform/cmd/server/main.go +++ b/multi-tenant-platform/cmd/server/main.go @@ -1,162 +1,24 @@ package main import ( - "encoding/json" - "fmt" - "log" - "net/http" - "os" - "time" + "fmt"; "log"; "net/http"; "os" + "multi-tenant-platform/internal/handlers" + "multi-tenant-platform/internal/repository" + "multi-tenant-platform/internal/service" ) func main() { port := os.Getenv("PORT") - if port == "" { - port = "8112" - } + if port == "" { port = "8112" } + repo := repository.NewTenantRepository() + svc := service.NewTenantService(repo) + h := handlers.NewHandler(svc) mux := http.NewServeMux() - mux.HandleFunc("/api/v1/tenants", handleListTenants) - mux.HandleFunc("/api/v1/tenants/create", handleCreateTenant) - mux.HandleFunc("/api/v1/tenants/config/", handleTenantConfig) - mux.HandleFunc("/api/v1/tenants/billing/", handleTenantBilling) - mux.HandleFunc("/api/v1/tenants/usage/", handleTenantUsage) + h.RegisterRoutes(mux) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"multi-tenant-platform"}`)) - }) - log.Printf("Multi-Tenant Platform starting on port %s", port) - if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { - log.Fatal(err) - } -} - -type Tenant struct { - ID string `json:"id"` - Name string `json:"name"` - Country string `json:"country"` - Plan string `json:"plan"` - Status string `json:"status"` - Domain string `json:"custom_domain,omitempty"` - Branding Branding `json:"branding"` - CreatedAt time.Time `json:"created_at"` - Policies int `json:"total_policies"` - MRR float64 `json:"mrr_usd"` -} - -type Branding struct { - LogoURL string `json:"logo_url"` - PrimaryColor string `json:"primary_color"` - CompanyName string `json:"company_name"` -} - -func handleListTenants(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "tenants": []Tenant{ - { - ID: "TNT-001", Name: "SafeGuard Insurance", Country: "NG", - Plan: "enterprise", Status: "active", - Domain: "safeguard.ngapp.ng", - Branding: Branding{LogoURL: "/logos/safeguard.png", PrimaryColor: "#1E40AF", CompanyName: "SafeGuard Insurance Ltd"}, - CreatedAt: time.Date(2025, 6, 1, 0, 0, 0, 0, time.UTC), - Policies: 45000, MRR: 5000, - }, - { - ID: "TNT-002", Name: "Bima Kenya", Country: "KE", - Plan: "growth", Status: "active", - Domain: "bima-ke.ngapp.ng", - Branding: Branding{LogoURL: "/logos/bima-ke.png", PrimaryColor: "#059669", CompanyName: "Bima Kenya Insurance"}, - CreatedAt: time.Date(2025, 9, 15, 0, 0, 0, 0, time.UTC), - Policies: 12000, MRR: 2000, - }, - { - ID: "TNT-003", Name: "AmanaCover", Country: "NG", - Plan: "starter", Status: "active", - Branding: Branding{LogoURL: "/logos/amana.png", PrimaryColor: "#7C3AED", CompanyName: "AmanaCover Takaful"}, - CreatedAt: time.Date(2026, 1, 10, 0, 0, 0, 0, time.UTC), - Policies: 3500, MRR: 500, - }, - }, - "total_tenants": 3, - "total_mrr_usd": 7500, - }) -} - -func handleCreateTenant(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { - Name string `json:"name"` - Country string `json:"country"` - Plan string `json:"plan"` - Email string `json:"admin_email"` - } - json.NewDecoder(r.Body).Decode(&req) - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]interface{}{ - "tenant_id": fmt.Sprintf("TNT-%d", time.Now().UnixNano()%100000), - "status": "provisioning", - "message": "Tenant environment being provisioned. Ready in ~2 minutes.", - "admin_url": fmt.Sprintf("https://%s.ngapp.ng/admin", "new-tenant"), - "setup_steps": []string{ - "Database schema created", - "Default products configured", - "Admin user invitation sent", - "Payment gateway sandbox configured", - "Custom domain DNS instructions sent", - }, - }) -} - -func handleTenantConfig(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "tenant_id": "TNT-001", - "config": map[string]interface{}{ - "products_enabled": []string{"motor_tp", "motor_comp", "term_life", "hospital_cash", "funeral_cover"}, - "payment_providers": []string{"paystack", "flutterwave", "opay"}, - "kyc_provider": "verifyMe", - "sms_provider": "africas_talking", - "whatsapp_enabled": true, - "ussd_code": "*384*001#", - "max_users": 50, - "max_agents": 200, - "api_rate_limit": 5000, - "data_retention_days": 2555, - "backup_frequency": "daily", - }, - }) -} - -func handleTenantBilling(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "plans": []map[string]interface{}{ - {"name": "Starter", "price_usd": 500, "policies_included": 5000, "users": 10, "agents": 50, "features": []string{"Core products", "SMS notifications", "Basic analytics"}}, - {"name": "Growth", "price_usd": 2000, "policies_included": 25000, "users": 25, "agents": 200, "features": []string{"All products", "WhatsApp + USSD", "AI claims", "Advanced analytics", "API access"}}, - {"name": "Enterprise", "price_usd": 5000, "policies_included": 100000, "users": -1, "agents": -1, "features": []string{"Everything", "Custom domain", "SLA 99.9%", "Dedicated support", "Custom integrations", "Multi-country"}}, - }, - }) -} - -func handleTenantUsage(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "tenant_id": "TNT-001", - "period": "2026-05", - "usage": map[string]interface{}{ - "policies_created": 1250, - "claims_processed": 340, - "api_calls": 85000, - "sms_sent": 4500, - "whatsapp_messages": 2800, - "storage_used_gb": 4.5, - "active_users": 35, - "active_agents": 120, - }, + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"healthy","service":"multi-tenant-platform","version":"2.0.0"}`)) }) + log.Printf("Multi-Tenant Platform v2.0 starting on port %s", port) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), mux)) } diff --git a/multi-tenant-platform/go.mod b/multi-tenant-platform/go.mod index e31d37b83..5cd36f288 100644 --- a/multi-tenant-platform/go.mod +++ b/multi-tenant-platform/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/multi-tenant-platform +module multi-tenant-platform go 1.22.0 diff --git a/multi-tenant-platform/internal/handlers/handlers.go b/multi-tenant-platform/internal/handlers/handlers.go new file mode 100644 index 000000000..66b9da00b --- /dev/null +++ b/multi-tenant-platform/internal/handlers/handlers.go @@ -0,0 +1,65 @@ +package handlers + +import ( + "encoding/json" + "multi-tenant-platform/internal/service" + "net/http" + "strings" +) + +type Handler struct { svc *service.TenantService } +func NewHandler(svc *service.TenantService) *Handler { return &Handler{svc: svc} } + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/tenants/create", h.Create) + mux.HandleFunc("/api/v1/tenants/tenant/", h.GetTenant) + mux.HandleFunc("/api/v1/tenants/list", h.List) + mux.HandleFunc("/api/v1/tenants/users/add", h.AddUser) + mux.HandleFunc("/api/v1/tenants/users/", h.GetUsers) + mux.HandleFunc("/api/v1/tenants/stats", h.GetStats) +} + +func rj(w http.ResponseWriter, s int, d interface{}) { w.Header().Set("Content-Type","application/json"); w.WriteHeader(s); json.NewEncoder(w).Encode(d) } +func re(w http.ResponseWriter, s int, m string) { rj(w, s, map[string]string{"error": m}) } + +func (h *Handler) Create(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { re(w, 405, "Method not allowed"); return } + var req service.CreateTenantRequest + json.NewDecoder(r.Body).Decode(&req) + t, err := h.svc.CreateTenant(req) + if err != nil { re(w, 400, err.Error()); return } + rj(w, 201, t) +} + +func (h *Handler) GetTenant(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/tenants/tenant/") + t, err := h.svc.GetTenant(id) + if err != nil { + t2, err2 := h.svc.GetTenantBySlug(id) + if err2 != nil { re(w, 404, err.Error()); return } + t = t2 + } + rj(w, 200, t) +} + +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + plan := r.URL.Query().Get("plan"); status := r.URL.Query().Get("status") + tenants := h.svc.ListTenants(plan, status) + rj(w, 200, map[string]interface{}{"tenants": tenants, "count": len(tenants)}) +} + +func (h *Handler) AddUser(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { re(w, 405, "Method not allowed"); return } + var req service.AddUserRequest + json.NewDecoder(r.Body).Decode(&req) + u, err := h.svc.AddUser(req) + if err != nil { re(w, 400, err.Error()); return } + rj(w, 201, u) +} + +func (h *Handler) GetUsers(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/tenants/users/") + rj(w, 200, map[string]interface{}{"users": h.svc.GetUsers(id)}) +} + +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { rj(w, 200, h.svc.GetStats()) } diff --git a/multi-tenant-platform/internal/models/tenant.go b/multi-tenant-platform/internal/models/tenant.go new file mode 100644 index 000000000..b931ece6f --- /dev/null +++ b/multi-tenant-platform/internal/models/tenant.go @@ -0,0 +1,44 @@ +package models + +import "time" + +type Tenant struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + Domain string `json:"domain,omitempty"` + Plan string `json:"plan"` + Status string `json:"status"` + Country string `json:"country"` + Currency string `json:"currency"` + RegulatorID string `json:"regulator_id,omitempty"` + MaxUsers int `json:"max_users"` + MaxPolicies int `json:"max_policies"` + CurrentUsers int `json:"current_users"` + CurrentPolicies int `json:"current_policies"` + StorageUsedMB int `json:"storage_used_mb"` + StorageLimitMB int `json:"storage_limit_mb"` + Features []string `json:"features"` + Settings map[string]string `json:"settings,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type TenantUser struct { + ID string `json:"id"` + TenantID string `json:"tenant_id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` + Status string `json:"status"` + JoinedAt time.Time `json:"joined_at"` +} + +type UsageRecord struct { + TenantID string `json:"tenant_id"` + Period string `json:"period"` + APICallsCount int `json:"api_calls"` + PoliciesCreated int `json:"policies_created"` + ClaimsProcessed int `json:"claims_processed"` + StorageDeltaMB int `json:"storage_delta_mb"` + RecordedAt time.Time `json:"recorded_at"` +} diff --git a/multi-tenant-platform/internal/repository/repository.go b/multi-tenant-platform/internal/repository/repository.go new file mode 100644 index 000000000..64629d0aa --- /dev/null +++ b/multi-tenant-platform/internal/repository/repository.go @@ -0,0 +1,111 @@ +package repository + +import ( + "fmt" + "multi-tenant-platform/internal/models" + "sync" + "time" +) + +type TenantRepository struct { + mu sync.RWMutex + tenants map[string]*models.Tenant + users map[string][]models.TenantUser + usage []models.UsageRecord +} + +func NewTenantRepository() *TenantRepository { + repo := &TenantRepository{ + tenants: make(map[string]*models.Tenant), + users: make(map[string][]models.TenantUser), + } + repo.seedTenants() + return repo +} + +func (r *TenantRepository) seedTenants() { + tenants := []models.Tenant{ + {ID: "TNT-001", Name: "AXA Mansard Nigeria", Slug: "axa-mansard", Plan: "enterprise", Status: "active", Country: "NG", Currency: "NGN", MaxUsers: 500, MaxPolicies: 100000, CurrentUsers: 120, CurrentPolicies: 45000, StorageUsedMB: 8500, StorageLimitMB: 50000, Features: []string{"claims", "underwriting", "reinsurance", "analytics", "api"}, CreatedAt: time.Now().AddDate(-2, 0, 0)}, + {ID: "TNT-002", Name: "Leadway Assurance", Slug: "leadway", Plan: "professional", Status: "active", Country: "NG", Currency: "NGN", MaxUsers: 200, MaxPolicies: 50000, CurrentUsers: 85, CurrentPolicies: 22000, StorageUsedMB: 4200, StorageLimitMB: 25000, Features: []string{"claims", "underwriting", "analytics"}, CreatedAt: time.Now().AddDate(-1, -6, 0)}, + {ID: "TNT-003", Name: "Jubilee Insurance Kenya", Slug: "jubilee-ke", Plan: "enterprise", Status: "active", Country: "KE", Currency: "KES", MaxUsers: 300, MaxPolicies: 75000, CurrentUsers: 95, CurrentPolicies: 38000, StorageUsedMB: 6100, StorageLimitMB: 50000, Features: []string{"claims", "underwriting", "reinsurance", "analytics", "api", "takaful"}, CreatedAt: time.Now().AddDate(-1, 0, 0)}, + } + for i := range tenants { + r.tenants[tenants[i].ID] = &tenants[i] + } +} + +func (r *TenantRepository) Create(t *models.Tenant) error { + r.mu.Lock() + defer r.mu.Unlock() + r.tenants[t.ID] = t + return nil +} + +func (r *TenantRepository) GetByID(id string) (*models.Tenant, error) { + r.mu.RLock() + defer r.mu.RUnlock() + t, ok := r.tenants[id] + if !ok { return nil, fmt.Errorf("tenant %s not found", id) } + return t, nil +} + +func (r *TenantRepository) GetBySlug(slug string) (*models.Tenant, error) { + r.mu.RLock() + defer r.mu.RUnlock() + for _, t := range r.tenants { + if t.Slug == slug { return t, nil } + } + return nil, fmt.Errorf("tenant with slug %s not found", slug) +} + +func (r *TenantRepository) List(plan, status string) []models.Tenant { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.Tenant + for _, t := range r.tenants { + if plan != "" && t.Plan != plan { continue } + if status != "" && t.Status != status { continue } + result = append(result, *t) + } + return result +} + +func (r *TenantRepository) Update(t *models.Tenant) { + r.mu.Lock() + defer r.mu.Unlock() + r.tenants[t.ID] = t +} + +func (r *TenantRepository) AddUser(u models.TenantUser) { + r.mu.Lock() + defer r.mu.Unlock() + r.users[u.TenantID] = append(r.users[u.TenantID], u) + if t, ok := r.tenants[u.TenantID]; ok { t.CurrentUsers++ } +} + +func (r *TenantRepository) GetUsers(tenantID string) []models.TenantUser { + r.mu.RLock() + defer r.mu.RUnlock() + return r.users[tenantID] +} + +func (r *TenantRepository) RecordUsage(u models.UsageRecord) { + r.mu.Lock() + defer r.mu.Unlock() + r.usage = append(r.usage, u) +} + +func (r *TenantRepository) GetStats() map[string]interface{} { + r.mu.RLock() + defer r.mu.RUnlock() + active := 0; totalPolicies := 0; totalUsers := 0 + for _, t := range r.tenants { + if t.Status == "active" { active++ } + totalPolicies += t.CurrentPolicies + totalUsers += t.CurrentUsers + } + return map[string]interface{}{ + "total_tenants": len(r.tenants), "active_tenants": active, + "total_policies": totalPolicies, "total_users": totalUsers, + } +} diff --git a/multi-tenant-platform/internal/service/service.go b/multi-tenant-platform/internal/service/service.go new file mode 100644 index 000000000..ffe61cb3f --- /dev/null +++ b/multi-tenant-platform/internal/service/service.go @@ -0,0 +1,75 @@ +package service + +import ( + "fmt" + "multi-tenant-platform/internal/models" + "multi-tenant-platform/internal/repository" + "strings" + "time" +) + +type TenantService struct { repo *repository.TenantRepository } +func NewTenantService(repo *repository.TenantRepository) *TenantService { return &TenantService{repo: repo} } + +type CreateTenantRequest struct { + Name string `json:"name"` + Country string `json:"country"` + Plan string `json:"plan"` +} + +var planLimits = map[string]struct{ users, policies, storageMB int; features []string }{ + "starter": {10, 1000, 1000, []string{"claims", "policies"}}, + "professional": {200, 50000, 25000, []string{"claims", "underwriting", "analytics"}}, + "enterprise": {500, 100000, 50000, []string{"claims", "underwriting", "reinsurance", "analytics", "api", "takaful"}}, +} + +func (s *TenantService) CreateTenant(req CreateTenantRequest) (*models.Tenant, error) { + if req.Name == "" { return nil, fmt.Errorf("name is required") } + limits, ok := planLimits[req.Plan] + if !ok { return nil, fmt.Errorf("invalid plan: %s (use starter, professional, enterprise)", req.Plan) } + + slug := strings.ToLower(strings.ReplaceAll(req.Name, " ", "-")) + currencyMap := map[string]string{"NG": "NGN", "KE": "KES", "GH": "GHS", "ZA": "ZAR", "EG": "EGP"} + currency := currencyMap[req.Country] + if currency == "" { currency = "USD" } + + tenant := &models.Tenant{ + ID: fmt.Sprintf("TNT-%d", time.Now().UnixNano()%10000000), + Name: req.Name, Slug: slug, Plan: req.Plan, Status: "active", + Country: req.Country, Currency: currency, + MaxUsers: limits.users, MaxPolicies: limits.policies, + StorageLimitMB: limits.storageMB, Features: limits.features, + CreatedAt: time.Now(), + } + if err := s.repo.Create(tenant); err != nil { return nil, err } + return tenant, nil +} + +func (s *TenantService) GetTenant(id string) (*models.Tenant, error) { return s.repo.GetByID(id) } +func (s *TenantService) GetTenantBySlug(slug string) (*models.Tenant, error) { return s.repo.GetBySlug(slug) } +func (s *TenantService) ListTenants(plan, status string) []models.Tenant { return s.repo.List(plan, status) } + +type AddUserRequest struct { + TenantID string `json:"tenant_id"` + Email string `json:"email"` + Name string `json:"name"` + Role string `json:"role"` +} + +func (s *TenantService) AddUser(req AddUserRequest) (*models.TenantUser, error) { + tenant, err := s.repo.GetByID(req.TenantID) + if err != nil { return nil, err } + if tenant.CurrentUsers >= tenant.MaxUsers { + return nil, fmt.Errorf("tenant %s has reached user limit (%d)", req.TenantID, tenant.MaxUsers) + } + user := models.TenantUser{ + ID: fmt.Sprintf("TU-%d", time.Now().UnixNano()%10000000), + TenantID: req.TenantID, Email: req.Email, Name: req.Name, + Role: req.Role, Status: "active", JoinedAt: time.Now(), + } + s.repo.AddUser(user) + return &user, nil +} + +func (s *TenantService) GetUsers(tenantID string) []models.TenantUser { return s.repo.GetUsers(tenantID) } +func (s *TenantService) GetStats() map[string]interface{} { return s.repo.GetStats() } diff --git a/notification-service/cmd/server/main.go b/notification-service/cmd/server/main.go index 39725c4cf..d5623e9c9 100644 --- a/notification-service/cmd/server/main.go +++ b/notification-service/cmd/server/main.go @@ -1,155 +1,27 @@ package main import ( - "encoding/json" "fmt" "log" "net/http" "os" - "time" + "notification-service/internal/handlers" + "notification-service/internal/repository" + "notification-service/internal/service" ) func main() { port := os.Getenv("PORT") - if port == "" { - port = "8109" - } + if port == "" { port = "8109" } + repo := repository.NewNotificationRepository() + svc := service.NewNotificationService(repo) + h := handlers.NewHandler(svc) mux := http.NewServeMux() - mux.HandleFunc("/api/v1/notifications/send", handleSend) - mux.HandleFunc("/api/v1/notifications/bulk", handleBulk) - mux.HandleFunc("/api/v1/notifications/preferences", handlePreferences) - mux.HandleFunc("/api/v1/notifications/channels", handleChannels) - mux.HandleFunc("/api/v1/notifications/history", handleHistory) + h.RegisterRoutes(mux) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"notification-service"}`)) - }) - log.Printf("Notification Service starting on port %s", port) - if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { - log.Fatal(err) - } -} - -type NotificationRequest struct { - RecipientID string `json:"recipient_id"` - Channels []string `json:"channels"` // sms, whatsapp, email, push, ussd - Template string `json:"template"` - Language string `json:"language"` - Data map[string]string `json:"data"` - Priority string `json:"priority"` // low, normal, high, urgent - ScheduleAt string `json:"schedule_at,omitempty"` -} - -type NotificationResponse struct { - NotificationID string `json:"notification_id"` - Status string `json:"status"` - ChannelResults []ChannelResult `json:"channel_results"` - SentAt time.Time `json:"sent_at"` -} - -type ChannelResult struct { - Channel string `json:"channel"` - Status string `json:"status"` - MessageID string `json:"message_id"` - Cost float64 `json:"cost_ngn"` -} - -func handleSend(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req NotificationRequest - json.NewDecoder(r.Body).Decode(&req) - if req.Language == "" { - req.Language = "en" - } - if len(req.Channels) == 0 { - req.Channels = []string{"sms"} - } - - results := make([]ChannelResult, len(req.Channels)) - for i, ch := range req.Channels { - cost := 0.0 - switch ch { - case "sms": - cost = 4.0 - case "whatsapp": - cost = 2.5 - case "email": - cost = 0.5 - case "push": - cost = 0.1 - } - results[i] = ChannelResult{ - Channel: ch, - Status: "delivered", - MessageID: fmt.Sprintf("MSG-%s-%d", ch, time.Now().UnixNano()%100000), - Cost: cost, - } - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(NotificationResponse{ - NotificationID: fmt.Sprintf("NTF-%d", time.Now().UnixNano()%1000000), - Status: "sent", - ChannelResults: results, - SentAt: time.Now(), - }) -} - -func handleBulk(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]interface{}{ - "batch_id": fmt.Sprintf("BATCH-%d", time.Now().UnixNano()%1000000), - "status": "queued", - "message": "Bulk notification batch queued for processing", - }) -} - -func handlePreferences(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "customer_id": "CUST-001", - "preferred_language": "en", - "channels": map[string]bool{ - "sms": true, "whatsapp": true, "email": true, "push": false, - }, - "quiet_hours": map[string]string{"start": "22:00", "end": "07:00"}, - "notification_types": map[string]bool{ - "payment_reminders": true, "claim_updates": true, - "policy_renewal": true, "marketing": false, - }, - }) -} - -func handleChannels(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "channels": []map[string]interface{}{ - {"id": "sms", "name": "SMS", "provider": "Africa's Talking", "cost_per_msg": 4.0, "delivery_rate": 0.97}, - {"id": "whatsapp", "name": "WhatsApp Business", "provider": "Meta Cloud API", "cost_per_msg": 2.5, "delivery_rate": 0.99}, - {"id": "email", "name": "Email", "provider": "SendGrid", "cost_per_msg": 0.5, "delivery_rate": 0.95}, - {"id": "push", "name": "Push Notification", "provider": "Firebase", "cost_per_msg": 0.1, "delivery_rate": 0.85}, - {"id": "ussd", "name": "USSD Flash", "provider": "Africa's Talking", "cost_per_msg": 3.0, "delivery_rate": 0.92}, - }, - }) -} - -func handleHistory(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "notifications": []map[string]interface{}{ - {"id": "NTF-001", "template": "payment_reminder", "channel": "sms", "status": "delivered", "sent_at": "2026-05-15T10:00:00Z"}, - {"id": "NTF-002", "template": "claim_update", "channel": "whatsapp", "status": "delivered", "sent_at": "2026-05-14T15:30:00Z"}, - {"id": "NTF-003", "template": "policy_renewal", "channel": "email", "status": "delivered", "sent_at": "2026-05-10T09:00:00Z"}, - }, - "total": 3, + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"healthy","service":"notification-service","version":"2.0.0"}`)) }) + log.Printf("Notification Service v2.0 starting on port %s", port) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), mux)) } diff --git a/notification-service/go.mod b/notification-service/go.mod index ca8b770de..ba4e0febe 100644 --- a/notification-service/go.mod +++ b/notification-service/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/notification-service +module notification-service go 1.22.0 diff --git a/notification-service/internal/handlers/handlers.go b/notification-service/internal/handlers/handlers.go new file mode 100644 index 000000000..8a91604aa --- /dev/null +++ b/notification-service/internal/handlers/handlers.go @@ -0,0 +1,91 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "notification-service/internal/models" + "notification-service/internal/service" + "strconv" + "strings" +) + +type Handler struct { svc *service.NotificationService } +func NewHandler(svc *service.NotificationService) *Handler { return &Handler{svc: svc} } + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/notifications/send", h.Send) + mux.HandleFunc("/api/v1/notifications/bulk", h.SendBulk) + mux.HandleFunc("/api/v1/notifications/notification/", h.Get) + mux.HandleFunc("/api/v1/notifications/list", h.List) + mux.HandleFunc("/api/v1/notifications/read/", h.MarkRead) + mux.HandleFunc("/api/v1/notifications/templates", h.GetTemplates) + mux.HandleFunc("/api/v1/notifications/preferences/", h.Preferences) + mux.HandleFunc("/api/v1/notifications/stats", h.GetStats) +} + +func rj(w http.ResponseWriter, s int, d interface{}) { w.Header().Set("Content-Type","application/json"); w.WriteHeader(s); json.NewEncoder(w).Encode(d) } +func re(w http.ResponseWriter, s int, m string) { rj(w, s, map[string]string{"error": m}) } + +func (h *Handler) Send(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { re(w, 405, "Method not allowed"); return } + var req service.SendRequest + json.NewDecoder(r.Body).Decode(&req) + n, err := h.svc.Send(req) + if err != nil { re(w, 400, err.Error()); return } + rj(w, 201, n) +} + +func (h *Handler) SendBulk(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { re(w, 405, "Method not allowed"); return } + var req service.BulkRequest + json.NewDecoder(r.Body).Decode(&req) + success, fail, err := h.svc.SendBulk(req) + if err != nil { re(w, 400, err.Error()); return } + rj(w, 200, map[string]interface{}{"success": success, "failed": fail}) +} + +func (h *Handler) Get(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/notifications/notification/") + n, err := h.svc.Get(id) + if err != nil { re(w, 404, err.Error()); return } + rj(w, 200, n) +} + +func (h *Handler) List(w http.ResponseWriter, r *http.Request) { + rid := r.URL.Query().Get("recipient_id"); status := r.URL.Query().Get("status") + limit := 50 + if l := r.URL.Query().Get("limit"); l != "" { if v, err := strconv.Atoi(l); err == nil { limit = v } } + notifs := h.svc.List(rid, status, limit) + rj(w, 200, map[string]interface{}{"notifications": notifs, "count": len(notifs)}) +} + +func (h *Handler) MarkRead(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { re(w, 405, "Method not allowed"); return } + id := strings.TrimPrefix(r.URL.Path, "/api/v1/notifications/read/") + if err := h.svc.MarkRead(id); err != nil { re(w, 400, err.Error()); return } + rj(w, 200, map[string]string{"status": "read"}) +} + +func (h *Handler) GetTemplates(w http.ResponseWriter, r *http.Request) { + cat := r.URL.Query().Get("category") + rj(w, 200, map[string]interface{}{"templates": h.svc.GetTemplates(cat)}) +} + +func (h *Handler) Preferences(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/notifications/preferences/") + if r.Method == http.MethodGet { + rj(w, 200, h.svc.GetPreference(id)) + return + } + if r.Method == http.MethodPut { + var pref models.NotificationPreference + json.NewDecoder(r.Body).Decode(&pref) + pref.RecipientID = id + h.svc.SetPreference(&pref) + rj(w, 200, map[string]string{"status": "updated"}) + return + } + re(w, 405, "Method not allowed") +} + +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { rj(w, 200, h.svc.GetStats()) } diff --git a/notification-service/internal/models/notification.go b/notification-service/internal/models/notification.go new file mode 100644 index 000000000..dca90e2ff --- /dev/null +++ b/notification-service/internal/models/notification.go @@ -0,0 +1,50 @@ +package models + +import "time" + +type NotificationType string +const ( + TypeSMS NotificationType = "sms" + TypeEmail NotificationType = "email" + TypePush NotificationType = "push" + TypeWhatsApp NotificationType = "whatsapp" + TypeInApp NotificationType = "in_app" +) + +type Notification struct { + ID string `json:"id"` + RecipientID string `json:"recipient_id"` + Type NotificationType `json:"type"` + Channel string `json:"channel"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + Status string `json:"status"` + Priority string `json:"priority"` + Metadata map[string]string `json:"metadata,omitempty"` + ProviderRef string `json:"provider_ref,omitempty"` + RetryCount int `json:"retry_count"` + SentAt *time.Time `json:"sent_at,omitempty"` + ReadAt *time.Time `json:"read_at,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type NotificationTemplate struct { + ID string `json:"id"` + Name string `json:"name"` + Type string `json:"type"` + Subject string `json:"subject"` + Body string `json:"body"` + Language string `json:"language"` + Category string `json:"category"` +} + +type NotificationPreference struct { + RecipientID string `json:"recipient_id"` + SMS bool `json:"sms"` + Email bool `json:"email"` + Push bool `json:"push"` + WhatsApp bool `json:"whatsapp"` + InApp bool `json:"in_app"` + QuietStart string `json:"quiet_hours_start"` + QuietEnd string `json:"quiet_hours_end"` +} diff --git a/notification-service/internal/repository/repository.go b/notification-service/internal/repository/repository.go new file mode 100644 index 000000000..e1ca3322c --- /dev/null +++ b/notification-service/internal/repository/repository.go @@ -0,0 +1,121 @@ +package repository + +import ( + "fmt" + "notification-service/internal/models" + "sync" + "time" +) + +type NotificationRepository struct { + mu sync.RWMutex + notifications map[string]*models.Notification + templates map[string]models.NotificationTemplate + preferences map[string]*models.NotificationPreference +} + +func NewNotificationRepository() *NotificationRepository { + repo := &NotificationRepository{ + notifications: make(map[string]*models.Notification), + templates: make(map[string]models.NotificationTemplate), + preferences: make(map[string]*models.NotificationPreference), + } + repo.seedTemplates() + return repo +} + +func (r *NotificationRepository) seedTemplates() { + templates := []models.NotificationTemplate{ + {ID: "TPL-001", Name: "premium_due", Type: "sms", Subject: "", Body: "Dear {{name}}, your {{product}} premium of {{currency}}{{amount}} is due on {{date}}. Pay via USSD *384*insurance# or visit our app.", Language: "en", Category: "billing"}, + {ID: "TPL-002", Name: "claim_approved", Type: "sms", Subject: "", Body: "Good news {{name}}! Your claim {{claim_id}} for {{currency}}{{amount}} has been approved. Payout within 24 hours.", Language: "en", Category: "claims"}, + {ID: "TPL-003", Name: "policy_expiry", Type: "email", Subject: "Policy Renewal Reminder", Body: "Dear {{name}}, your {{product}} policy expires on {{date}}. Renew now to maintain coverage.", Language: "en", Category: "renewal"}, + {ID: "TPL-004", Name: "welcome", Type: "whatsapp", Subject: "", Body: "Welcome to NGInsure, {{name}}! 🎉 Your {{product}} policy is now active. Policy ID: {{policy_id}}", Language: "en", Category: "onboarding"}, + {ID: "TPL-005", Name: "payment_received", Type: "sms", Subject: "", Body: "Payment of {{currency}}{{amount}} received for policy {{policy_id}}. Thank you {{name}}!", Language: "en", Category: "billing"}, + {ID: "TPL-006", Name: "premium_due_yo", Type: "sms", Subject: "", Body: "Ẹ kú ilẹ̀ {{name}}, owó iṣeduro {{product}} ti {{currency}}{{amount}} ti tó lati san ni {{date}}.", Language: "yo", Category: "billing"}, + {ID: "TPL-007", Name: "premium_due_ha", Type: "sms", Subject: "", Body: "Barka da yamma {{name}}, kudin inshorar {{product}} na {{currency}}{{amount}} ya kamata a biya a {{date}}.", Language: "ha", Category: "billing"}, + } + for _, t := range templates { + r.templates[t.ID] = t + } +} + +func (r *NotificationRepository) Create(n *models.Notification) error { + r.mu.Lock() + defer r.mu.Unlock() + r.notifications[n.ID] = n + return nil +} + +func (r *NotificationRepository) GetByID(id string) (*models.Notification, error) { + r.mu.RLock() + defer r.mu.RUnlock() + n, ok := r.notifications[id] + if !ok { return nil, fmt.Errorf("notification %s not found", id) } + return n, nil +} + +func (r *NotificationRepository) UpdateStatus(id, status string) { + r.mu.Lock() + defer r.mu.Unlock() + if n, ok := r.notifications[id]; ok { + n.Status = status + if status == "sent" { now := time.Now(); n.SentAt = &now } + if status == "read" { now := time.Now(); n.ReadAt = &now } + } +} + +func (r *NotificationRepository) List(recipientID, status string, limit int) []models.Notification { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.Notification + for _, n := range r.notifications { + if recipientID != "" && n.RecipientID != recipientID { continue } + if status != "" && n.Status != status { continue } + result = append(result, *n) + if limit > 0 && len(result) >= limit { break } + } + return result +} + +func (r *NotificationRepository) GetTemplates(category string) []models.NotificationTemplate { + var result []models.NotificationTemplate + for _, t := range r.templates { + if category == "" || t.Category == category { + result = append(result, t) + } + } + return result +} + +func (r *NotificationRepository) GetPreference(id string) *models.NotificationPreference { + r.mu.RLock() + defer r.mu.RUnlock() + if p, ok := r.preferences[id]; ok { return p } + return &models.NotificationPreference{RecipientID: id, SMS: true, Email: true, Push: true, WhatsApp: true, InApp: true} +} + +func (r *NotificationRepository) SetPreference(p *models.NotificationPreference) { + r.mu.Lock() + defer r.mu.Unlock() + r.preferences[p.RecipientID] = p +} + +func (r *NotificationRepository) GetStats() map[string]interface{} { + r.mu.RLock() + defer r.mu.RUnlock() + total := len(r.notifications) + sent, failed, read := 0, 0, 0 + byType := map[string]int{} + for _, n := range r.notifications { + byType[string(n.Type)]++ + switch n.Status { + case "sent": sent++ + case "failed": failed++ + case "read": read++ + } + } + return map[string]interface{}{ + "total": total, "sent": sent, "failed": failed, "read": read, + "by_type": byType, "delivery_rate": float64(sent) / float64(total+1) * 100, + } +} diff --git a/notification-service/internal/service/service.go b/notification-service/internal/service/service.go new file mode 100644 index 000000000..3aa1b559b --- /dev/null +++ b/notification-service/internal/service/service.go @@ -0,0 +1,128 @@ +package service + +import ( + "fmt" + "notification-service/internal/models" + "notification-service/internal/repository" + "strings" + "time" +) + +type NotificationService struct { + repo *repository.NotificationRepository +} + +func NewNotificationService(repo *repository.NotificationRepository) *NotificationService { + return &NotificationService{repo: repo} +} + +type SendRequest struct { + RecipientID string `json:"recipient_id"` + Type string `json:"type"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + Priority string `json:"priority,omitempty"` + Metadata map[string]string `json:"metadata,omitempty"` + TemplateID string `json:"template_id,omitempty"` + Vars map[string]string `json:"template_vars,omitempty"` +} + +func (s *NotificationService) Send(req SendRequest) (*models.Notification, error) { + if req.RecipientID == "" { + return nil, fmt.Errorf("recipient_id is required") + } + + pref := s.repo.GetPreference(req.RecipientID) + nType := models.NotificationType(req.Type) + switch nType { + case models.TypeSMS: + if !pref.SMS { return nil, fmt.Errorf("recipient has opted out of SMS") } + case models.TypeEmail: + if !pref.Email { return nil, fmt.Errorf("recipient has opted out of email") } + case models.TypeWhatsApp: + if !pref.WhatsApp { return nil, fmt.Errorf("recipient has opted out of WhatsApp") } + } + + body := req.Body + if req.TemplateID != "" && req.Vars != nil { + templates := s.repo.GetTemplates("") + for _, t := range templates { + if t.ID == req.TemplateID { + body = t.Body + for k, v := range req.Vars { + body = strings.ReplaceAll(body, "{{"+k+"}}", v) + } + if req.Subject == "" { req.Subject = t.Subject } + break + } + } + } + if body == "" { + return nil, fmt.Errorf("notification body is empty") + } + + priority := req.Priority + if priority == "" { priority = "normal" } + + notif := &models.Notification{ + ID: fmt.Sprintf("NOT-%d", time.Now().UnixNano()%10000000), + RecipientID: req.RecipientID, + Type: nType, + Subject: req.Subject, + Body: body, + Status: "queued", + Priority: priority, + Metadata: req.Metadata, + CreatedAt: time.Now(), + } + + if err := s.repo.Create(notif); err != nil { + return nil, err + } + + go s.processAsync(notif.ID) + + return notif, nil +} + +func (s *NotificationService) processAsync(id string) { + time.Sleep(1 * time.Second) + s.repo.UpdateStatus(id, "sent") +} + +type BulkRequest struct { + RecipientIDs []string `json:"recipient_ids"` + Type string `json:"type"` + Subject string `json:"subject,omitempty"` + Body string `json:"body"` + Priority string `json:"priority,omitempty"` +} + +func (s *NotificationService) SendBulk(req BulkRequest) (int, int, error) { + if len(req.RecipientIDs) == 0 { + return 0, 0, fmt.Errorf("no recipients specified") + } + success, fail := 0, 0 + for _, rid := range req.RecipientIDs { + _, err := s.Send(SendRequest{ + RecipientID: rid, Type: req.Type, Subject: req.Subject, + Body: req.Body, Priority: req.Priority, + }) + if err != nil { fail++ } else { success++ } + } + return success, fail, nil +} + +func (s *NotificationService) MarkRead(id string) error { + _, err := s.repo.GetByID(id) + if err != nil { return err } + s.repo.UpdateStatus(id, "read") + return nil +} + +func (s *NotificationService) Get(id string) (*models.Notification, error) { return s.repo.GetByID(id) } +func (s *NotificationService) List(recipientID, status string, limit int) []models.Notification { return s.repo.List(recipientID, status, limit) } +func (s *NotificationService) GetTemplates(category string) []models.NotificationTemplate { return s.repo.GetTemplates(category) } +func (s *NotificationService) GetPreference(id string) *models.NotificationPreference { return s.repo.GetPreference(id) } +func (s *NotificationService) SetPreference(p *models.NotificationPreference) { s.repo.SetPreference(p) } +func (s *NotificationService) GetStats() map[string]interface{} { return s.repo.GetStats() } diff --git a/pan-african-ekyc/cmd/server/main.go b/pan-african-ekyc/cmd/server/main.go index a91a40b88..00933e82d 100644 --- a/pan-african-ekyc/cmd/server/main.go +++ b/pan-african-ekyc/cmd/server/main.go @@ -1,139 +1,24 @@ package main import ( - "encoding/json" - "fmt" - "log" - "net/http" - "os" - "time" + "fmt"; "log"; "net/http"; "os" + "pan-african-ekyc/internal/handlers" + "pan-african-ekyc/internal/repository" + "pan-african-ekyc/internal/service" ) func main() { port := os.Getenv("PORT") - if port == "" { - port = "8106" - } + if port == "" { port = "8106" } + repo := repository.NewEKYCRepository() + svc := service.NewEKYCService(repo) + h := handlers.NewHandler(svc) mux := http.NewServeMux() - mux.HandleFunc("/api/v1/ekyc/verify", handleVerify) - mux.HandleFunc("/api/v1/ekyc/providers", handleProviders) - mux.HandleFunc("/api/v1/ekyc/id-types/", handleIDTypes) - mux.HandleFunc("/api/v1/ekyc/risk-level", handleRiskLevel) + h.RegisterRoutes(mux) mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { - w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"pan-african-ekyc"}`)) - }) - log.Printf("Pan-African eKYC starting on port %s", port) - if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { - log.Fatal(err) - } -} - -type VerifyRequest struct { - Country string `json:"country"` - IDType string `json:"id_type"` - IDNumber string `json:"id_number"` - FirstName string `json:"first_name"` - LastName string `json:"last_name"` - DateOfBirth string `json:"date_of_birth,omitempty"` - PhoneNumber string `json:"phone_number,omitempty"` -} - -type VerifyResponse struct { - VerificationID string `json:"verification_id"` - Status string `json:"status"` // verified, failed, pending, partial - Country string `json:"country"` - IDType string `json:"id_type"` - Confidence float64 `json:"confidence"` - NameMatch bool `json:"name_match"` - DOBMatch bool `json:"dob_match"` - PhotoMatch float64 `json:"photo_match_score,omitempty"` - Provider string `json:"provider"` - VerifiedAt time.Time `json:"verified_at"` - RiskFlags []string `json:"risk_flags,omitempty"` -} - -func handleVerify(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req VerifyRequest - json.NewDecoder(r.Body).Decode(&req) - - providerMap := map[string]string{ - "NG": "NIMC/VerifyMe", "KE": "IPRS/Smile Identity", - "GH": "NIA/Appruve", "ZA": "DHA/Idenfy", - "RW": "NIDA", "TZ": "NIDA/Smile Identity", - } - provider := providerMap[req.Country] - if provider == "" { - provider = "Smile Identity (Pan-African)" - } - - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(VerifyResponse{ - VerificationID: fmt.Sprintf("VRF-%d", time.Now().UnixNano()%1000000), - Status: "verified", - Country: req.Country, - IDType: req.IDType, - Confidence: 0.97, - NameMatch: true, - DOBMatch: true, - PhotoMatch: 0.95, - Provider: provider, - VerifiedAt: time.Now(), - }) -} - -func handleProviders(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "providers": []map[string]interface{}{ - {"name": "Smile Identity", "countries": []string{"NG", "KE", "GH", "ZA", "TZ", "UG", "RW"}, "type": "aggregator"}, - {"name": "VerifyMe", "countries": []string{"NG"}, "type": "local_specialist"}, - {"name": "Appruve", "countries": []string{"GH", "KE", "NG"}, "type": "aggregator"}, - {"name": "Prembly (Identitypass)", "countries": []string{"NG", "KE", "GH"}, "type": "aggregator"}, - }, - }) -} - -func handleIDTypes(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "countries": map[string][]map[string]string{ - "NG": { - {"type": "bvn", "name": "Bank Verification Number", "format": "11 digits"}, - {"type": "nin", "name": "National Identification Number", "format": "11 digits"}, - {"type": "drivers_license", "name": "Driver's License"}, - {"type": "voters_card", "name": "Voter's Card"}, - {"type": "passport", "name": "International Passport"}, - }, - "KE": { - {"type": "national_id", "name": "National ID", "format": "8 digits"}, - {"type": "kra_pin", "name": "KRA PIN"}, - {"type": "passport", "name": "Passport"}, - }, - "GH": { - {"type": "ghana_card", "name": "Ghana Card", "format": "GHA-XXXXXXXXX-X"}, - {"type": "voters_id", "name": "Voter's ID"}, - {"type": "ssnit", "name": "SSNIT Number"}, - }, - "ZA": { - {"type": "sa_id", "name": "South African ID Number", "format": "13 digits"}, - {"type": "passport", "name": "Passport"}, - }, - }, - }) -} - -func handleRiskLevel(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "levels": []map[string]interface{}{ - {"level": "basic", "requirements": []string{"Phone number verification", "Name + Date of Birth"}, "max_coverage": 100000, "products": []string{"microinsurance", "device_protect"}}, - {"level": "standard", "requirements": []string{"Government ID verification", "Selfie + Liveness check"}, "max_coverage": 5000000, "products": []string{"motor", "health", "funeral"}}, - {"level": "enhanced", "requirements": []string{"Full document verification", "Address verification", "Income verification"}, "max_coverage": 50000000, "products": []string{"comprehensive_motor", "term_life", "group_life"}}, - }, + w.Header().Set("Content-Type", "application/json") + w.Write([]byte(`{"status":"healthy","service":"pan-african-ekyc","version":"2.0.0"}`)) }) + log.Printf("Pan-African eKYC v2.0 starting on port %s", port) + log.Fatal(http.ListenAndServe(fmt.Sprintf(":%s", port), mux)) } diff --git a/pan-african-ekyc/go.mod b/pan-african-ekyc/go.mod index 72b6ab882..572005ccf 100644 --- a/pan-african-ekyc/go.mod +++ b/pan-african-ekyc/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/pan-african-ekyc +module pan-african-ekyc go 1.22.0 diff --git a/pan-african-ekyc/internal/handlers/handlers.go b/pan-african-ekyc/internal/handlers/handlers.go new file mode 100644 index 000000000..f281c5620 --- /dev/null +++ b/pan-african-ekyc/internal/handlers/handlers.go @@ -0,0 +1,53 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "pan-african-ekyc/internal/service" + "strings" +) + +type Handler struct { svc *service.EKYCService } +func NewHandler(svc *service.EKYCService) *Handler { return &Handler{svc: svc} } + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/ekyc/verify", h.Verify) + mux.HandleFunc("/api/v1/ekyc/verification/", h.GetVerification) + mux.HandleFunc("/api/v1/ekyc/verifications", h.ListVerifications) + mux.HandleFunc("/api/v1/ekyc/profile/", h.GetProfile) + mux.HandleFunc("/api/v1/ekyc/documents", h.GetDocuments) + mux.HandleFunc("/api/v1/ekyc/stats", h.GetStats) +} + +func rj(w http.ResponseWriter, s int, d interface{}) { w.Header().Set("Content-Type","application/json"); w.WriteHeader(s); json.NewEncoder(w).Encode(d) } +func re(w http.ResponseWriter, s int, m string) { rj(w, s, map[string]string{"error": m}) } + +func (h *Handler) Verify(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { re(w, 405, "Method not allowed"); return } + var req service.VerifyRequest + json.NewDecoder(r.Body).Decode(&req) + v, err := h.svc.Verify(req) + if err != nil { re(w, 400, err.Error()); return } + rj(w, 200, v) +} +func (h *Handler) GetVerification(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/ekyc/verification/") + v, err := h.svc.GetVerification(id) + if err != nil { re(w, 404, err.Error()); return } + rj(w, 200, v) +} +func (h *Handler) ListVerifications(w http.ResponseWriter, r *http.Request) { + cid := r.URL.Query().Get("customer_id") + rj(w, 200, map[string]interface{}{"verifications": h.svc.ListVerifications(cid)}) +} +func (h *Handler) GetProfile(w http.ResponseWriter, r *http.Request) { + cid := strings.TrimPrefix(r.URL.Path, "/api/v1/ekyc/profile/") + p, err := h.svc.GetProfile(cid) + if err != nil { re(w, 404, err.Error()); return } + rj(w, 200, p) +} +func (h *Handler) GetDocuments(w http.ResponseWriter, r *http.Request) { + country := r.URL.Query().Get("country") + rj(w, 200, map[string]interface{}{"documents": h.svc.GetSupportedDocuments(country)}) +} +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { rj(w, 200, h.svc.GetStats()) } diff --git a/pan-african-ekyc/internal/models/ekyc.go b/pan-african-ekyc/internal/models/ekyc.go new file mode 100644 index 000000000..34218fb97 --- /dev/null +++ b/pan-african-ekyc/internal/models/ekyc.go @@ -0,0 +1,64 @@ +package models + +import "time" + +type VerificationType string +const ( + VerifyNIN VerificationType = "nin" + VerifyBVN VerificationType = "bvn" + VerifyPassport VerificationType = "passport" + VerifyDrivers VerificationType = "drivers_license" + VerifyVoterID VerificationType = "voter_id" + VerifyCAC VerificationType = "cac" + VerifyTIN VerificationType = "tin" +) + +type VerificationRequest struct { + ID string `json:"id"` + CustomerID string `json:"customer_id"` + Type VerificationType `json:"type"` + Country string `json:"country"` + DocumentID string `json:"document_id"` + FullName string `json:"full_name"` + DateOfBirth string `json:"date_of_birth,omitempty"` + Status string `json:"status"` + Score float64 `json:"verification_score"` + MatchDetails MatchResult `json:"match_details"` + RiskFlags []string `json:"risk_flags,omitempty"` + Provider string `json:"provider"` + ProviderRef string `json:"provider_ref,omitempty"` + CreatedAt time.Time `json:"created_at"` + CompletedAt *time.Time `json:"completed_at,omitempty"` +} + +type MatchResult struct { + NameMatch float64 `json:"name_match_pct"` + DOBMatch bool `json:"dob_match"` + PhotoMatch float64 `json:"photo_match_pct"` + AddressMatch float64 `json:"address_match_pct"` + Overall float64 `json:"overall_pct"` +} + +type KYCProfile struct { + ID string `json:"id"` + CustomerID string `json:"customer_id"` + FullName string `json:"full_name"` + Country string `json:"country"` + Level string `json:"kyc_level"` + VerifiedDocs []string `json:"verified_documents"` + RiskScore float64 `json:"risk_score"` + PEPCheck bool `json:"pep_check_passed"` + SanctionsCheck bool `json:"sanctions_check_passed"` + AMLCheck bool `json:"aml_check_passed"` + Status string `json:"status"` + ExpiresAt time.Time `json:"expires_at"` + CreatedAt time.Time `json:"created_at"` +} + +type SupportedDocument struct { + Country string `json:"country"` + Type string `json:"type"` + Name string `json:"name"` + Provider string `json:"provider"` + Format string `json:"format_hint"` +} diff --git a/pan-african-ekyc/internal/repository/repository.go b/pan-african-ekyc/internal/repository/repository.go new file mode 100644 index 000000000..c154ea4e3 --- /dev/null +++ b/pan-african-ekyc/internal/repository/repository.go @@ -0,0 +1,113 @@ +package repository + +import ( + "fmt" + "pan-african-ekyc/internal/models" + "sync" + "time" +) + +type EKYCRepository struct { + mu sync.RWMutex + verifications map[string]*models.VerificationRequest + profiles map[string]*models.KYCProfile + documents []models.SupportedDocument +} + +func NewEKYCRepository() *EKYCRepository { + repo := &EKYCRepository{ + verifications: make(map[string]*models.VerificationRequest), + profiles: make(map[string]*models.KYCProfile), + } + repo.seedDocuments() + return repo +} + +func (r *EKYCRepository) seedDocuments() { + r.documents = []models.SupportedDocument{ + {Country: "NG", Type: "nin", Name: "National Identification Number", Provider: "NIMC", Format: "11 digits"}, + {Country: "NG", Type: "bvn", Name: "Bank Verification Number", Provider: "NIBSS", Format: "11 digits"}, + {Country: "NG", Type: "voter_id", Name: "Voter's Card", Provider: "INEC", Format: "19 characters"}, + {Country: "NG", Type: "drivers_license", Name: "Driver's License", Provider: "FRSC", Format: "FG/state/year/number"}, + {Country: "NG", Type: "cac", Name: "CAC Registration", Provider: "CAC", Format: "RC + number"}, + {Country: "NG", Type: "tin", Name: "Tax Identification Number", Provider: "FIRS", Format: "10 digits"}, + {Country: "KE", Type: "national_id", Name: "National ID Card", Provider: "IPRS", Format: "8 digits"}, + {Country: "KE", Type: "kra_pin", Name: "KRA PIN", Provider: "KRA", Format: "A + 9 digits + letter"}, + {Country: "GH", Type: "ghana_card", Name: "Ghana Card", Provider: "NIA", Format: "GHA-XXXXXXXX-X"}, + {Country: "ZA", Type: "sa_id", Name: "South African ID", Provider: "DHA", Format: "13 digits"}, + {Country: "RW", Type: "nid", Name: "National ID", Provider: "NIDA", Format: "16 digits"}, + } +} + +func (r *EKYCRepository) CreateVerification(v *models.VerificationRequest) error { + r.mu.Lock() + defer r.mu.Unlock() + r.verifications[v.ID] = v + return nil +} + +func (r *EKYCRepository) GetVerification(id string) (*models.VerificationRequest, error) { + r.mu.RLock() + defer r.mu.RUnlock() + v, ok := r.verifications[id] + if !ok { return nil, fmt.Errorf("verification %s not found", id) } + return v, nil +} + +func (r *EKYCRepository) ListVerifications(customerID string) []models.VerificationRequest { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.VerificationRequest + for _, v := range r.verifications { + if customerID == "" || v.CustomerID == customerID { result = append(result, *v) } + } + return result +} + +func (r *EKYCRepository) CreateProfile(p *models.KYCProfile) error { + r.mu.Lock() + defer r.mu.Unlock() + r.profiles[p.CustomerID] = p + return nil +} + +func (r *EKYCRepository) GetProfile(customerID string) (*models.KYCProfile, error) { + r.mu.RLock() + defer r.mu.RUnlock() + p, ok := r.profiles[customerID] + if !ok { return nil, fmt.Errorf("KYC profile not found for %s", customerID) } + return p, nil +} + +func (r *EKYCRepository) UpdateProfile(p *models.KYCProfile) { + r.mu.Lock() + defer r.mu.Unlock() + r.profiles[p.CustomerID] = p +} + +func (r *EKYCRepository) GetSupportedDocuments(country string) []models.SupportedDocument { + var result []models.SupportedDocument + for _, d := range r.documents { + if country == "" || d.Country == country { result = append(result, d) } + } + return result +} + +func (r *EKYCRepository) GetStats() map[string]interface{} { + r.mu.RLock() + defer r.mu.RUnlock() + verified, pending, failed := 0, 0, 0 + for _, v := range r.verifications { + switch v.Status { + case "verified": verified++ + case "pending": pending++ + case "failed": failed++ + } + } + return map[string]interface{}{ + "total_verifications": len(r.verifications), "verified": verified, "pending": pending, "failed": failed, + "kyc_profiles": len(r.profiles), "supported_countries": 6, + } +} + +func init() { _ = time.Now } diff --git a/pan-african-ekyc/internal/service/service.go b/pan-african-ekyc/internal/service/service.go new file mode 100644 index 000000000..fc96bca81 --- /dev/null +++ b/pan-african-ekyc/internal/service/service.go @@ -0,0 +1,101 @@ +package service + +import ( + "fmt" + "math" + "math/rand" + "pan-african-ekyc/internal/models" + "pan-african-ekyc/internal/repository" + "time" +) + +type EKYCService struct { repo *repository.EKYCRepository } +func NewEKYCService(repo *repository.EKYCRepository) *EKYCService { return &EKYCService{repo: repo} } + +type VerifyRequest struct { + CustomerID string `json:"customer_id"` + Type string `json:"type"` + Country string `json:"country"` + DocumentID string `json:"document_id"` + FullName string `json:"full_name"` + DateOfBirth string `json:"date_of_birth,omitempty"` +} + +func (s *EKYCService) Verify(req VerifyRequest) (*models.VerificationRequest, error) { + if req.CustomerID == "" || req.DocumentID == "" { + return nil, fmt.Errorf("customer_id and document_id are required") + } + if req.Country == "" { return nil, fmt.Errorf("country is required") } + + nameMatch := 85.0 + rand.Float64()*15 + photoMatch := 80.0 + rand.Float64()*20 + addrMatch := 70.0 + rand.Float64()*30 + overall := (nameMatch*0.4 + photoMatch*0.35 + addrMatch*0.25) + + status := "verified" + if overall < 70 { status = "failed" } else if overall < 85 { status = "review" } + + var riskFlags []string + if overall < 80 { riskFlags = append(riskFlags, "low_match_score") } + + now := time.Now() + v := &models.VerificationRequest{ + ID: fmt.Sprintf("VRF-%d", time.Now().UnixNano()%10000000), + CustomerID: req.CustomerID, + Type: models.VerificationType(req.Type), + Country: req.Country, + DocumentID: req.DocumentID, + FullName: req.FullName, + DateOfBirth: req.DateOfBirth, + Status: status, + Score: math.Round(overall*10) / 10, + MatchDetails: models.MatchResult{ + NameMatch: math.Round(nameMatch*10) / 10, + DOBMatch: true, + PhotoMatch: math.Round(photoMatch*10) / 10, + AddressMatch: math.Round(addrMatch*10) / 10, + Overall: math.Round(overall*10) / 10, + }, + RiskFlags: riskFlags, + Provider: "NGInsure eKYC", + CreatedAt: now, + CompletedAt: &now, + } + + if err := s.repo.CreateVerification(v); err != nil { return nil, err } + + s.updateKYCProfile(req.CustomerID, req.FullName, req.Country, req.Type, status) + + return v, nil +} + +func (s *EKYCService) updateKYCProfile(customerID, name, country, docType, status string) { + profile, err := s.repo.GetProfile(customerID) + if err != nil { + profile = &models.KYCProfile{ + ID: fmt.Sprintf("KYC-%d", time.Now().UnixNano()%10000000), + CustomerID: customerID, FullName: name, Country: country, + Level: "basic", PEPCheck: true, SanctionsCheck: true, AMLCheck: true, + Status: "active", ExpiresAt: time.Now().AddDate(1, 0, 0), CreatedAt: time.Now(), + } + s.repo.CreateProfile(profile) + } + + if status == "verified" { + found := false + for _, d := range profile.VerifiedDocs { + if d == docType { found = true; break } + } + if !found { profile.VerifiedDocs = append(profile.VerifiedDocs, docType) } + + if len(profile.VerifiedDocs) >= 3 { profile.Level = "enhanced" } else if len(profile.VerifiedDocs) >= 1 { profile.Level = "standard" } + profile.RiskScore = math.Max(0, 100-float64(len(profile.VerifiedDocs))*15) + s.repo.UpdateProfile(profile) + } +} + +func (s *EKYCService) GetVerification(id string) (*models.VerificationRequest, error) { return s.repo.GetVerification(id) } +func (s *EKYCService) ListVerifications(customerID string) []models.VerificationRequest { return s.repo.ListVerifications(customerID) } +func (s *EKYCService) GetProfile(customerID string) (*models.KYCProfile, error) { return s.repo.GetProfile(customerID) } +func (s *EKYCService) GetSupportedDocuments(country string) []models.SupportedDocument { return s.repo.GetSupportedDocuments(country) } +func (s *EKYCService) GetStats() map[string]interface{} { return s.repo.GetStats() } diff --git a/premium-finance-service/cmd/server/main.go b/premium-finance-service/cmd/server/main.go index 1919bd97b..40a618b13 100644 --- a/premium-finance-service/cmd/server/main.go +++ b/premium-finance-service/cmd/server/main.go @@ -1,13 +1,14 @@ package main import ( - "encoding/json" "fmt" "log" - "math" "net/http" "os" - "time" + + "premium-finance-service/internal/handlers" + "premium-finance-service/internal/repository" + "premium-finance-service/internal/service" ) func main() { @@ -15,114 +16,22 @@ func main() { if port == "" { port = "8103" } + + repo := repository.NewFinanceRepository() + svc := service.NewFinanceService(repo) + handler := handlers.NewHandler(svc) + mux := http.NewServeMux() - mux.HandleFunc("/api/v1/finance/plans", handlePlans) - mux.HandleFunc("/api/v1/finance/create", handleCreate) - mux.HandleFunc("/api/v1/finance/payment", handlePayment) - mux.HandleFunc("/api/v1/finance/schedule/", handleSchedule) + handler.RegisterRoutes(mux) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"premium-finance-service"}`)) + w.Write([]byte(`{"status":"healthy","service":"premium-finance-service","version":"2.0.0"}`)) }) - log.Printf("Premium Finance Service starting on port %s", port) + + log.Printf("Premium Finance Service v2.0 starting on port %s", port) if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { log.Fatal(err) } } - -type InstallmentPlan struct { - PlanID string `json:"plan_id"` - PolicyID string `json:"policy_id"` - TotalPremium float64 `json:"total_premium"` - DownPayment float64 `json:"down_payment"` - Installments int `json:"installments"` - MonthlyAmount float64 `json:"monthly_amount"` - InterestRate float64 `json:"interest_rate"` - TotalCost float64 `json:"total_cost"` - Status string `json:"status"` - NextDue time.Time `json:"next_due_date"` -} - -func handlePlans(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "plans": []map[string]interface{}{ - {"type": "monthly_3", "installments": 3, "interest_rate": 0, "down_payment_pct": 0.40, "description": "3 months (interest-free)"}, - {"type": "monthly_6", "installments": 6, "interest_rate": 0.05, "down_payment_pct": 0.25, "description": "6 months (5% interest)"}, - {"type": "monthly_12", "installments": 12, "interest_rate": 0.10, "down_payment_pct": 0.15, "description": "12 months (10% interest)"}, - {"type": "pay_as_you_go", "installments": 0, "interest_rate": 0, "down_payment_pct": 0, "description": "Daily/weekly micro-payments"}, - }, - }) -} - -func handleCreate(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var req struct { - PolicyID string `json:"policy_id"` - TotalPremium float64 `json:"total_premium"` - PlanType string `json:"plan_type"` - } - json.NewDecoder(r.Body).Decode(&req) - - var installments int - var interestRate, downPaymentPct float64 - switch req.PlanType { - case "monthly_3": - installments = 3; interestRate = 0; downPaymentPct = 0.40 - case "monthly_6": - installments = 6; interestRate = 0.05; downPaymentPct = 0.25 - case "monthly_12": - installments = 12; interestRate = 0.10; downPaymentPct = 0.15 - default: - installments = 3; interestRate = 0; downPaymentPct = 0.40 - } - - downPayment := req.TotalPremium * downPaymentPct - financedAmount := req.TotalPremium - downPayment - totalInterest := financedAmount * interestRate - monthlyAmount := math.Round((financedAmount+totalInterest)/float64(installments)*100) / 100 - totalCost := downPayment + monthlyAmount*float64(installments) - - planID := fmt.Sprintf("FIN-%d", time.Now().UnixNano()%1000000) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(InstallmentPlan{ - PlanID: planID, - PolicyID: req.PolicyID, - TotalPremium: req.TotalPremium, - DownPayment: downPayment, - Installments: installments, - MonthlyAmount: monthlyAmount, - InterestRate: interestRate, - TotalCost: math.Round(totalCost*100) / 100, - Status: "active", - NextDue: time.Now().AddDate(0, 1, 0), - }) -} - -func handlePayment(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "payment_recorded", - "remaining_installments": 2, - "next_due": time.Now().AddDate(0, 1, 0).Format(time.RFC3339), - }) -} - -func handleSchedule(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "schedule": []map[string]interface{}{ - {"installment": 1, "amount": 10000, "due_date": "2026-06-01", "status": "paid"}, - {"installment": 2, "amount": 10000, "due_date": "2026-07-01", "status": "upcoming"}, - {"installment": 3, "amount": 10000, "due_date": "2026-08-01", "status": "upcoming"}, - }, - }) -} diff --git a/premium-finance-service/go.mod b/premium-finance-service/go.mod index 964b964d5..56b74aaf0 100644 --- a/premium-finance-service/go.mod +++ b/premium-finance-service/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/premium-finance-service +module premium-finance-service go 1.22.0 diff --git a/premium-finance-service/internal/handlers/handlers.go b/premium-finance-service/internal/handlers/handlers.go new file mode 100644 index 000000000..ad1ffbd93 --- /dev/null +++ b/premium-finance-service/internal/handlers/handlers.go @@ -0,0 +1,103 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "premium-finance-service/internal/models" + "premium-finance-service/internal/service" + "strconv" + "strings" +) + +type Handler struct { + svc *service.FinanceService +} + +func NewHandler(svc *service.FinanceService) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/finance/apply", h.Apply) + mux.HandleFunc("/api/v1/finance/loan/", h.GetLoan) + mux.HandleFunc("/api/v1/finance/loans", h.ListLoans) + mux.HandleFunc("/api/v1/finance/schedule/", h.GetSchedule) + mux.HandleFunc("/api/v1/finance/payment", h.MakePayment) + mux.HandleFunc("/api/v1/finance/stats", h.GetStats) +} + +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func respondError(w http.ResponseWriter, status int, msg string) { + respondJSON(w, status, map[string]string{"error": msg}) +} + +func (h *Handler) Apply(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + var app models.LoanApplication + if err := json.NewDecoder(r.Body).Decode(&app); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body") + return + } + loan, err := h.svc.ApplyForLoan(app) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusCreated, loan) +} + +func (h *Handler) GetLoan(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/finance/loan/") + loan, err := h.svc.GetLoan(id) + if err != nil { + respondError(w, http.StatusNotFound, err.Error()) + return + } + respondJSON(w, http.StatusOK, loan) +} + +func (h *Handler) ListLoans(w http.ResponseWriter, r *http.Request) { + status := r.URL.Query().Get("status") + loans := h.svc.ListLoans(status) + respondJSON(w, http.StatusOK, map[string]interface{}{"loans": loans, "count": len(loans)}) +} + +func (h *Handler) GetSchedule(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/finance/schedule/") + schedule := h.svc.GetSchedule(id) + respondJSON(w, http.StatusOK, map[string]interface{}{"schedule": schedule}) +} + +func (h *Handler) MakePayment(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + var req struct { + LoanID string `json:"loan_id"` + Number int `json:"installment_number"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request body") + return + } + _ = strconv.Itoa(req.Number) + inst, err := h.svc.MakePayment(req.LoanID, req.Number) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusOK, inst) +} + +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, h.svc.GetStats()) +} diff --git a/premium-finance-service/internal/models/finance.go b/premium-finance-service/internal/models/finance.go new file mode 100644 index 000000000..578230016 --- /dev/null +++ b/premium-finance-service/internal/models/finance.go @@ -0,0 +1,60 @@ +package models + +import "time" + +type LoanStatus string + +const ( + LoanPending LoanStatus = "pending" + LoanApproved LoanStatus = "approved" + LoanActive LoanStatus = "active" + LoanPaidOff LoanStatus = "paid_off" + LoanDefaulted LoanStatus = "defaulted" + LoanRejected LoanStatus = "rejected" +) + +type PremiumLoan struct { + ID string `json:"id"` + PolicyID string `json:"policy_id"` + CustomerID string `json:"customer_id"` + PremiumAmount float64 `json:"premium_amount"` + LoanAmount float64 `json:"loan_amount"` + DownPayment float64 `json:"down_payment"` + InterestRate float64 `json:"interest_rate"` + Tenure int `json:"tenure_months"` + MonthlyPayment float64 `json:"monthly_payment"` + TotalInterest float64 `json:"total_interest"` + TotalRepayment float64 `json:"total_repayment"` + OutstandingBalance float64 `json:"outstanding_balance"` + Status LoanStatus `json:"status"` + CreditScore int `json:"credit_score"` + RiskCategory string `json:"risk_category"` + DisbursedAt *time.Time `json:"disbursed_at,omitempty"` + NextPaymentDate *time.Time `json:"next_payment_date,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type Installment struct { + ID string `json:"id"` + LoanID string `json:"loan_id"` + Number int `json:"installment_number"` + Amount float64 `json:"amount"` + Principal float64 `json:"principal"` + Interest float64 `json:"interest"` + Balance float64 `json:"outstanding_after"` + DueDate time.Time `json:"due_date"` + PaidDate *time.Time `json:"paid_date,omitempty"` + Status string `json:"status"` + LateFee float64 `json:"late_fee"` +} + +type LoanApplication struct { + PolicyID string `json:"policy_id"` + CustomerID string `json:"customer_id"` + PremiumAmount float64 `json:"premium_amount"` + DownPaymentPct float64 `json:"down_payment_pct"` + Tenure int `json:"tenure_months"` + CreditScore int `json:"credit_score"` + MonthlyIncome float64 `json:"monthly_income"` + Employer string `json:"employer"` +} diff --git a/premium-finance-service/internal/repository/repository.go b/premium-finance-service/internal/repository/repository.go new file mode 100644 index 000000000..c2fec9462 --- /dev/null +++ b/premium-finance-service/internal/repository/repository.go @@ -0,0 +1,113 @@ +package repository + +import ( + "fmt" + "premium-finance-service/internal/models" + "sync" + "time" +) + +type FinanceRepository struct { + mu sync.RWMutex + loans map[string]*models.PremiumLoan + installments map[string][]models.Installment +} + +func NewFinanceRepository() *FinanceRepository { + return &FinanceRepository{ + loans: make(map[string]*models.PremiumLoan), + installments: make(map[string][]models.Installment), + } +} + +func (r *FinanceRepository) CreateLoan(loan *models.PremiumLoan) error { + r.mu.Lock() + defer r.mu.Unlock() + r.loans[loan.ID] = loan + return nil +} + +func (r *FinanceRepository) GetLoan(id string) (*models.PremiumLoan, error) { + r.mu.RLock() + defer r.mu.RUnlock() + loan, ok := r.loans[id] + if !ok { + return nil, fmt.Errorf("loan %s not found", id) + } + return loan, nil +} + +func (r *FinanceRepository) UpdateLoan(loan *models.PremiumLoan) error { + r.mu.Lock() + defer r.mu.Unlock() + r.loans[loan.ID] = loan + return nil +} + +func (r *FinanceRepository) ListLoans(status string) []models.PremiumLoan { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.PremiumLoan + for _, l := range r.loans { + if status == "" || string(l.Status) == status { + result = append(result, *l) + } + } + return result +} + +func (r *FinanceRepository) SaveInstallments(loanID string, installments []models.Installment) { + r.mu.Lock() + defer r.mu.Unlock() + r.installments[loanID] = installments +} + +func (r *FinanceRepository) GetInstallments(loanID string) []models.Installment { + r.mu.RLock() + defer r.mu.RUnlock() + return r.installments[loanID] +} + +func (r *FinanceRepository) PayInstallment(loanID string, number int) error { + r.mu.Lock() + defer r.mu.Unlock() + installments, ok := r.installments[loanID] + if !ok { + return fmt.Errorf("no installments for loan %s", loanID) + } + for i := range installments { + if installments[i].Number == number { + now := time.Now() + installments[i].PaidDate = &now + installments[i].Status = "paid" + if now.After(installments[i].DueDate) { + dayslate := int(now.Sub(installments[i].DueDate).Hours() / 24) + installments[i].LateFee = float64(dayslate) * 500 + } + return nil + } + } + return fmt.Errorf("installment %d not found", number) +} + +func (r *FinanceRepository) GetStats() map[string]interface{} { + r.mu.RLock() + defer r.mu.RUnlock() + var total, active, defaulted int + var totalDisbursed, totalOutstanding float64 + for _, l := range r.loans { + total++ + switch l.Status { + case models.LoanActive: + active++ + totalOutstanding += l.OutstandingBalance + case models.LoanDefaulted: + defaulted++ + } + totalDisbursed += l.LoanAmount + } + return map[string]interface{}{ + "total_loans": total, "active_loans": active, "defaulted_loans": defaulted, + "total_disbursed": totalDisbursed, "total_outstanding": totalOutstanding, + } +} diff --git a/premium-finance-service/internal/service/service.go b/premium-finance-service/internal/service/service.go new file mode 100644 index 000000000..0cf1ec218 --- /dev/null +++ b/premium-finance-service/internal/service/service.go @@ -0,0 +1,179 @@ +package service + +import ( + "fmt" + "math" + "premium-finance-service/internal/models" + "premium-finance-service/internal/repository" + "time" +) + +type FinanceService struct { + repo *repository.FinanceRepository +} + +func NewFinanceService(repo *repository.FinanceRepository) *FinanceService { + return &FinanceService{repo: repo} +} + +func (s *FinanceService) ApplyForLoan(app models.LoanApplication) (*models.PremiumLoan, error) { + if app.PremiumAmount <= 0 { + return nil, fmt.Errorf("premium amount must be positive") + } + if app.Tenure < 1 || app.Tenure > 12 { + return nil, fmt.Errorf("tenure must be between 1 and 12 months") + } + if app.DownPaymentPct < 0 || app.DownPaymentPct > 100 { + return nil, fmt.Errorf("down payment percentage must be 0-100") + } + if app.CreditScore < 300 || app.CreditScore > 850 { + return nil, fmt.Errorf("credit score must be 300-850") + } + + riskCategory, interestRate := s.assessCreditRisk(app.CreditScore, app.MonthlyIncome, app.PremiumAmount) + + if riskCategory == "very_high" { + return nil, fmt.Errorf("loan application rejected: credit risk too high (score: %d)", app.CreditScore) + } + + downPayment := app.PremiumAmount * app.DownPaymentPct / 100 + loanAmount := app.PremiumAmount - downPayment + monthlyRate := interestRate / 12 / 100 + var monthlyPayment float64 + if monthlyRate == 0 { + monthlyPayment = loanAmount / float64(app.Tenure) + } else { + monthlyPayment = loanAmount * monthlyRate * math.Pow(1+monthlyRate, float64(app.Tenure)) / (math.Pow(1+monthlyRate, float64(app.Tenure)) - 1) + } + monthlyPayment = math.Round(monthlyPayment*100) / 100 + totalRepayment := monthlyPayment * float64(app.Tenure) + totalInterest := totalRepayment - loanAmount + + if app.MonthlyIncome > 0 && monthlyPayment > app.MonthlyIncome*0.4 { + return nil, fmt.Errorf("monthly payment ₦%.2f exceeds 40%% of monthly income ₦%.2f", monthlyPayment, app.MonthlyIncome) + } + + now := time.Now() + nextPayment := now.AddDate(0, 1, 0) + loan := &models.PremiumLoan{ + ID: fmt.Sprintf("PFL-%d", time.Now().UnixNano()%10000000), + PolicyID: app.PolicyID, + CustomerID: app.CustomerID, + PremiumAmount: app.PremiumAmount, + LoanAmount: loanAmount, + DownPayment: downPayment, + InterestRate: interestRate, + Tenure: app.Tenure, + MonthlyPayment: monthlyPayment, + TotalInterest: math.Round(totalInterest*100) / 100, + TotalRepayment: math.Round(totalRepayment*100) / 100, + OutstandingBalance: loanAmount, + Status: models.LoanApproved, + CreditScore: app.CreditScore, + RiskCategory: riskCategory, + DisbursedAt: &now, + NextPaymentDate: &nextPayment, + CreatedAt: now, + } + + if err := s.repo.CreateLoan(loan); err != nil { + return nil, err + } + + installments := s.generateSchedule(loan) + s.repo.SaveInstallments(loan.ID, installments) + + return loan, nil +} + +func (s *FinanceService) assessCreditRisk(score int, income, premium float64) (string, float64) { + switch { + case score >= 750: + return "low", 8.0 + case score >= 650: + return "moderate", 14.0 + case score >= 550: + return "high", 22.0 + case score >= 450: + return "high", 28.0 + default: + return "very_high", 35.0 + } +} + +func (s *FinanceService) generateSchedule(loan *models.PremiumLoan) []models.Installment { + var schedule []models.Installment + balance := loan.LoanAmount + monthlyRate := loan.InterestRate / 12 / 100 + + for i := 1; i <= loan.Tenure; i++ { + interest := math.Round(balance*monthlyRate*100) / 100 + principal := math.Round((loan.MonthlyPayment-interest)*100) / 100 + if i == loan.Tenure { + principal = balance + } + balance = math.Round((balance-principal)*100) / 100 + if balance < 0 { + balance = 0 + } + dueDate := loan.CreatedAt.AddDate(0, i, 0) + schedule = append(schedule, models.Installment{ + ID: fmt.Sprintf("INS-%s-%d", loan.ID, i), + LoanID: loan.ID, + Number: i, + Amount: loan.MonthlyPayment, + Principal: principal, + Interest: interest, + Balance: balance, + DueDate: dueDate, + Status: "pending", + }) + } + return schedule +} + +func (s *FinanceService) GetLoan(id string) (*models.PremiumLoan, error) { + return s.repo.GetLoan(id) +} + +func (s *FinanceService) ListLoans(status string) []models.PremiumLoan { + return s.repo.ListLoans(status) +} + +func (s *FinanceService) GetSchedule(loanID string) []models.Installment { + return s.repo.GetInstallments(loanID) +} + +func (s *FinanceService) MakePayment(loanID string, number int) (*models.Installment, error) { + if err := s.repo.PayInstallment(loanID, number); err != nil { + return nil, err + } + loan, _ := s.repo.GetLoan(loanID) + installments := s.repo.GetInstallments(loanID) + allPaid := true + for _, inst := range installments { + if inst.Number == number { + loan.OutstandingBalance -= inst.Principal + } + if inst.Status != "paid" { + allPaid = false + } + } + if allPaid { + loan.Status = models.LoanPaidOff + } else { + loan.Status = models.LoanActive + } + s.repo.UpdateLoan(loan) + + for _, inst := range installments { + if inst.Number == number { + return &inst, nil + } + } + return nil, fmt.Errorf("installment not found after payment") +} + +func (s *FinanceService) GetStats() map[string]interface{} { + return s.repo.GetStats() +} diff --git a/takaful-module/cmd/server/main.go b/takaful-module/cmd/server/main.go index 6ea64d548..e253a0b29 100644 --- a/takaful-module/cmd/server/main.go +++ b/takaful-module/cmd/server/main.go @@ -1,12 +1,14 @@ package main import ( - "encoding/json" "fmt" "log" "net/http" "os" - "time" + + "takaful-module/internal/handlers" + "takaful-module/internal/repository" + "takaful-module/internal/service" ) func main() { @@ -14,186 +16,22 @@ func main() { if port == "" { port = "8098" } + + repo := repository.NewTakafulRepository() + svc := service.NewTakafulService(repo) + handler := handlers.NewHandler(svc) + mux := http.NewServeMux() - mux.HandleFunc("/api/v1/takaful/products", handleProducts) - mux.HandleFunc("/api/v1/takaful/enroll", handleEnroll) - mux.HandleFunc("/api/v1/takaful/funds", handleFunds) - mux.HandleFunc("/api/v1/takaful/surplus", handleSurplus) - mux.HandleFunc("/api/v1/takaful/shariah-board", handleShariahBoard) - mux.HandleFunc("/api/v1/takaful/claim", handleClaim) + handler.RegisterRoutes(mux) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"takaful-module"}`)) + w.Write([]byte(`{"status":"healthy","service":"takaful-module","version":"2.0.0"}`)) }) - log.Printf("Takaful Module starting on port %s", port) + + log.Printf("Takaful Module v2.0 starting on port %s", port) if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { log.Fatal(err) } } - -// TakafulProduct represents a Shariah-compliant insurance product -type TakafulProduct struct { - ID string `json:"id"` - Name string `json:"name"` - NameArabic string `json:"name_arabic"` - Type string `json:"type"` // general, family (life equivalent) - Model string `json:"model"` // wakala, mudaraba, hybrid - Contribution float64 `json:"min_contribution_ngn"` - Benefits []string `json:"benefits"` - ShariahCompliant bool `json:"shariah_compliant"` - FatwahReference string `json:"fatwah_reference"` -} - -// TakafulFund represents the shared risk pool -type TakafulFund struct { - FundID string `json:"fund_id"` - FundType string `json:"fund_type"` // risk_fund, investment_fund - TotalContributions float64 `json:"total_contributions"` - ClaimsPaid float64 `json:"claims_paid"` - InvestmentIncome float64 `json:"investment_income"` - OperatorFee float64 `json:"operator_fee"` // Wakala fee - Surplus float64 `json:"surplus"` - Deficit float64 `json:"deficit"` - Participants int `json:"participants"` -} - -func handleProducts(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - products := []TakafulProduct{ - { - ID: "TKF-FAM-001", Name: "Family Takaful", NameArabic: "\u062a\u0643\u0627\u0641\u0644 \u0639\u0627\u0626\u0644\u064a", - Type: "family", Model: "hybrid", - Contribution: 2000, ShariahCompliant: true, - FatwahReference: "NAICOM/SHB/2024/001", - Benefits: []string{ - "Death benefit (Ta'awun)", - "Total permanent disability", - "Critical illness cover", - "Surplus sharing with participants", - "Shariah-compliant investments only", - }, - }, - { - ID: "TKF-MTR-001", Name: "Motor Takaful", NameArabic: "\u062a\u0643\u0627\u0641\u0644 \u0627\u0644\u0633\u064a\u0627\u0631\u0627\u062a", - Type: "general", Model: "wakala", - Contribution: 5000, ShariahCompliant: true, - FatwahReference: "NAICOM/SHB/2024/002", - Benefits: []string{ - "Third party liability", - "Own damage (comprehensive option)", - "Towing and emergency assistance", - "No interest (riba-free)", - "Annual surplus distribution", - }, - }, - { - ID: "TKF-HLT-001", Name: "Health Takaful", NameArabic: "\u062a\u0643\u0627\u0641\u0644 \u0635\u062d\u064a", - Type: "general", Model: "wakala", - Contribution: 3000, ShariahCompliant: true, - FatwahReference: "NAICOM/SHB/2024/003", - Benefits: []string{ - "Hospitalization benefit", - "Outpatient care", - "Maternity cover", - "Shariah-compliant hospitals network", - }, - }, - { - ID: "TKF-AGR-001", Name: "Agricultural Takaful", NameArabic: "\u062a\u0643\u0627\u0641\u0644 \u0632\u0631\u0627\u0639\u064a", - Type: "general", Model: "mudaraba", - Contribution: 1500, ShariahCompliant: true, - FatwahReference: "NAICOM/SHB/2024/004", - Benefits: []string{ - "Crop loss protection", - "Livestock mortality cover", - "Drought and flood protection", - "Profit sharing from agricultural investments", - }, - }, - } - json.NewEncoder(w).Encode(map[string]interface{}{"products": products}) -} - -func handleEnroll(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]interface{}{ - "certificate_number": fmt.Sprintf("NGA-TKF-%d", time.Now().UnixNano()%1000000), - "status": "active", - "model": "wakala", - "wakala_fee": "20%", - "message": "Alhamdulillah! Your Takaful certificate has been issued. Details sent via SMS.", - }) -} - -func handleFunds(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "funds": []TakafulFund{ - { - FundID: "FUND-RISK-001", FundType: "risk_fund", - TotalContributions: 150000000, ClaimsPaid: 45000000, - InvestmentIncome: 12000000, OperatorFee: 30000000, - Surplus: 87000000, Deficit: 0, Participants: 5000, - }, - { - FundID: "FUND-INV-001", FundType: "investment_fund", - TotalContributions: 300000000, ClaimsPaid: 0, - InvestmentIncome: 42000000, OperatorFee: 15000000, - Surplus: 327000000, Deficit: 0, Participants: 5000, - }, - }, - "investment_policy": map[string]interface{}{ - "allowed": []string{"Sukuk bonds", "Shariah-compliant equities", "Real estate", "Islamic money market"}, - "prohibited": []string{"Interest-bearing instruments", "Gambling", "Alcohol", "Pork-related"}, - }, - }) -} - -func handleSurplus(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "period": "2025", - "total_surplus": 87000000, - "distribution_method": "Pro-rata based on contribution", - "participant_share": "70%", - "operator_share": "30%", - "per_participant": 12180, - "distribution_date": "2026-03-31", - "status": "distributed", - }) -} - -func handleShariahBoard(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "board_name": "NGApp Shariah Advisory Board", - "members": []map[string]string{ - {"name": "Sheikh Ahmad Ibrahim", "role": "Chairman", "qualification": "PhD Islamic Finance, Al-Azhar University"}, - {"name": "Dr. Aisha Bello", "role": "Member", "qualification": "MSc Islamic Banking, IIUM Malaysia"}, - {"name": "Ustaz Yusuf Abdullahi", "role": "Member", "qualification": "Fiqh Muamalat, Madinah University"}, - }, - "certification_status": "All products certified Shariah-compliant", - "last_audit": "2026-01-15", - "next_audit": "2026-07-15", - }) -} - -func handleClaim(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusCreated) - json.NewEncoder(w).Encode(map[string]interface{}{ - "claim_number": fmt.Sprintf("NGA-TKC-%d", time.Now().UnixNano()%1000000), - "status": "submitted", - "fund_source": "Risk Fund (Ta'awun Pool)", - "message": "Your claim has been submitted from the mutual aid fund. In sha Allah, we will process it within 48 hours.", - }) -} diff --git a/takaful-module/go.mod b/takaful-module/go.mod index 8e2e5c209..bcd029a77 100644 --- a/takaful-module/go.mod +++ b/takaful-module/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/takaful-module +module takaful-module go 1.22.0 diff --git a/takaful-module/internal/handlers/handlers.go b/takaful-module/internal/handlers/handlers.go new file mode 100644 index 000000000..8cb5b649c --- /dev/null +++ b/takaful-module/internal/handlers/handlers.go @@ -0,0 +1,104 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strings" + "takaful-module/internal/service" +) + +type Handler struct { + svc *service.TakafulService +} + +func NewHandler(svc *service.TakafulService) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/takaful/funds", h.GetFunds) + mux.HandleFunc("/api/v1/takaful/fund/", h.GetFund) + mux.HandleFunc("/api/v1/takaful/join", h.JoinFund) + mux.HandleFunc("/api/v1/takaful/participant/", h.GetParticipant) + mux.HandleFunc("/api/v1/takaful/participants", h.ListParticipants) + mux.HandleFunc("/api/v1/takaful/contributions/", h.GetContributions) + mux.HandleFunc("/api/v1/takaful/surplus/distribute", h.DistributeSurplus) + mux.HandleFunc("/api/v1/takaful/surplus/history/", h.GetDistributions) + mux.HandleFunc("/api/v1/takaful/compliance/check", h.ComplianceCheck) + mux.HandleFunc("/api/v1/takaful/compliance/", h.GetCompliance) +} + +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func respondError(w http.ResponseWriter, status int, msg string) { + respondJSON(w, status, map[string]string{"error": msg}) +} + +func (h *Handler) GetFunds(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, map[string]interface{}{"funds": h.svc.GetFunds()}) +} + +func (h *Handler) GetFund(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/takaful/fund/") + f, err := h.svc.GetFund(id) + if err != nil { respondError(w, http.StatusNotFound, err.Error()); return } + respondJSON(w, http.StatusOK, f) +} + +func (h *Handler) JoinFund(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { respondError(w, http.StatusMethodNotAllowed, "Method not allowed"); return } + var req service.JoinRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { respondError(w, http.StatusBadRequest, "Invalid request"); return } + p, err := h.svc.JoinFund(req) + if err != nil { respondError(w, http.StatusBadRequest, err.Error()); return } + respondJSON(w, http.StatusCreated, p) +} + +func (h *Handler) GetParticipant(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/takaful/participant/") + p, err := h.svc.GetParticipant(id) + if err != nil { respondError(w, http.StatusNotFound, err.Error()); return } + respondJSON(w, http.StatusOK, p) +} + +func (h *Handler) ListParticipants(w http.ResponseWriter, r *http.Request) { + fundID := r.URL.Query().Get("fund_id") + respondJSON(w, http.StatusOK, map[string]interface{}{"participants": h.svc.ListParticipants(fundID)}) +} + +func (h *Handler) GetContributions(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/takaful/contributions/") + respondJSON(w, http.StatusOK, map[string]interface{}{"contributions": h.svc.GetContributions(id)}) +} + +func (h *Handler) DistributeSurplus(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { respondError(w, http.StatusMethodNotAllowed, "Method not allowed"); return } + var req struct { FundID string `json:"fund_id"`; Period string `json:"period"` } + json.NewDecoder(r.Body).Decode(&req) + dist, err := h.svc.DistributeSurplus(req.FundID, req.Period) + if err != nil { respondError(w, http.StatusBadRequest, err.Error()); return } + respondJSON(w, http.StatusOK, dist) +} + +func (h *Handler) GetDistributions(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/takaful/surplus/history/") + respondJSON(w, http.StatusOK, map[string]interface{}{"distributions": h.svc.GetDistributions(id)}) +} + +func (h *Handler) ComplianceCheck(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { respondError(w, http.StatusMethodNotAllowed, "Method not allowed"); return } + var req struct { FundID string `json:"fund_id"` } + json.NewDecoder(r.Body).Decode(&req) + check, err := h.svc.RunComplianceCheck(req.FundID) + if err != nil { respondError(w, http.StatusBadRequest, err.Error()); return } + respondJSON(w, http.StatusOK, check) +} + +func (h *Handler) GetCompliance(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/takaful/compliance/") + respondJSON(w, http.StatusOK, map[string]interface{}{"compliance": h.svc.GetCompliance(id)}) +} diff --git a/takaful-module/internal/models/takaful.go b/takaful-module/internal/models/takaful.go new file mode 100644 index 000000000..1d90d1f9a --- /dev/null +++ b/takaful-module/internal/models/takaful.go @@ -0,0 +1,77 @@ +package models + +import "time" + +type ContributionType string + +const ( + Tabarru ContributionType = "tabarru" + Investment ContributionType = "investment" + Wakala ContributionType = "wakala_fee" +) + +type TakafulFund struct { + ID string `json:"id"` + Name string `json:"name"` + FundType string `json:"fund_type"` + TotalContributions float64 `json:"total_contributions"` + TabarruPool float64 `json:"tabarru_pool"` + InvestmentPool float64 `json:"investment_pool"` + ClaimsPaid float64 `json:"claims_paid"` + SurplusAmount float64 `json:"surplus_amount"` + WakalaFeeRate float64 `json:"wakala_fee_rate"` + MudharabaShare float64 `json:"mudharaba_share"` + ParticipantCount int `json:"participant_count"` + IsActive bool `json:"is_active"` + CreatedAt time.Time `json:"created_at"` +} + +type TakafulParticipant struct { + ID string `json:"id"` + FundID string `json:"fund_id"` + CustomerID string `json:"customer_id"` + Name string `json:"name"` + ContributionAmt float64 `json:"contribution_amount"` + TabarruPortion float64 `json:"tabarru_portion"` + InvestPortion float64 `json:"investment_portion"` + WakalaFee float64 `json:"wakala_fee"` + SurplusShare float64 `json:"surplus_share"` + CoverageAmount float64 `json:"coverage_amount"` + Status string `json:"status"` + JoinedAt time.Time `json:"joined_at"` +} + +type TakafulContribution struct { + ID string `json:"id"` + ParticipantID string `json:"participant_id"` + FundID string `json:"fund_id"` + Amount float64 `json:"amount"` + Type ContributionType `json:"type"` + TabarruAmount float64 `json:"tabarru_amount"` + InvestAmount float64 `json:"invest_amount"` + WakalaAmount float64 `json:"wakala_amount"` + Period string `json:"period"` + CreatedAt time.Time `json:"created_at"` +} + +type SurplusDistribution struct { + ID string `json:"id"` + FundID string `json:"fund_id"` + Period string `json:"period"` + TotalSurplus float64 `json:"total_surplus"` + DistributedAmt float64 `json:"distributed_amount"` + RetainedAmt float64 `json:"retained_amount"` + ParticipantCnt int `json:"participant_count"` + PerCapitaShare float64 `json:"per_capita_share"` + DistributedAt time.Time `json:"distributed_at"` +} + +type ShariaCompliance struct { + ID string `json:"id"` + FundID string `json:"fund_id"` + CheckType string `json:"check_type"` + Status string `json:"status"` + Details string `json:"details"` + Auditor string `json:"auditor"` + CheckedAt time.Time `json:"checked_at"` +} diff --git a/takaful-module/internal/repository/repository.go b/takaful-module/internal/repository/repository.go new file mode 100644 index 000000000..75b3ab421 --- /dev/null +++ b/takaful-module/internal/repository/repository.go @@ -0,0 +1,146 @@ +package repository + +import ( + "fmt" + "sync" + "takaful-module/internal/models" + "time" +) + +type TakafulRepository struct { + mu sync.RWMutex + funds map[string]*models.TakafulFund + participants map[string]*models.TakafulParticipant + contributions []models.TakafulContribution + distributions []models.SurplusDistribution + compliance []models.ShariaCompliance +} + +func NewTakafulRepository() *TakafulRepository { + repo := &TakafulRepository{ + funds: make(map[string]*models.TakafulFund), + participants: make(map[string]*models.TakafulParticipant), + } + repo.seedFunds() + return repo +} + +func (r *TakafulRepository) seedFunds() { + funds := []models.TakafulFund{ + {ID: "TF-001", Name: "Family Takaful Fund", FundType: "family", TotalContributions: 25000000, TabarruPool: 7500000, InvestmentPool: 15000000, ClaimsPaid: 3200000, SurplusAmount: 1800000, WakalaFeeRate: 0.10, MudharabaShare: 0.60, ParticipantCount: 1250, IsActive: true, CreatedAt: time.Now().AddDate(-1, 0, 0)}, + {ID: "TF-002", Name: "General Takaful Fund", FundType: "general", TotalContributions: 18000000, TabarruPool: 5400000, InvestmentPool: 10800000, ClaimsPaid: 2100000, SurplusAmount: 950000, WakalaFeeRate: 0.12, MudharabaShare: 0.55, ParticipantCount: 890, IsActive: true, CreatedAt: time.Now().AddDate(-1, 0, 0)}, + {ID: "TF-003", Name: "Health Takaful Fund", FundType: "health", TotalContributions: 12000000, TabarruPool: 4800000, InvestmentPool: 6000000, ClaimsPaid: 3500000, SurplusAmount: 200000, WakalaFeeRate: 0.08, MudharabaShare: 0.65, ParticipantCount: 620, IsActive: true, CreatedAt: time.Now().AddDate(0, -6, 0)}, + } + for i := range funds { + r.funds[funds[i].ID] = &funds[i] + } +} + +func (r *TakafulRepository) GetFunds() []models.TakafulFund { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.TakafulFund + for _, f := range r.funds { + result = append(result, *f) + } + return result +} + +func (r *TakafulRepository) GetFund(id string) (*models.TakafulFund, error) { + r.mu.RLock() + defer r.mu.RUnlock() + f, ok := r.funds[id] + if !ok { + return nil, fmt.Errorf("fund %s not found", id) + } + return f, nil +} + +func (r *TakafulRepository) UpdateFund(f *models.TakafulFund) { + r.mu.Lock() + defer r.mu.Unlock() + r.funds[f.ID] = f +} + +func (r *TakafulRepository) AddParticipant(p *models.TakafulParticipant) error { + r.mu.Lock() + defer r.mu.Unlock() + r.participants[p.ID] = p + return nil +} + +func (r *TakafulRepository) GetParticipant(id string) (*models.TakafulParticipant, error) { + r.mu.RLock() + defer r.mu.RUnlock() + p, ok := r.participants[id] + if !ok { + return nil, fmt.Errorf("participant %s not found", id) + } + return p, nil +} + +func (r *TakafulRepository) ListParticipants(fundID string) []models.TakafulParticipant { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.TakafulParticipant + for _, p := range r.participants { + if fundID == "" || p.FundID == fundID { + result = append(result, *p) + } + } + return result +} + +func (r *TakafulRepository) AddContribution(c models.TakafulContribution) { + r.mu.Lock() + defer r.mu.Unlock() + r.contributions = append(r.contributions, c) +} + +func (r *TakafulRepository) GetContributions(participantID string) []models.TakafulContribution { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.TakafulContribution + for _, c := range r.contributions { + if c.ParticipantID == participantID { + result = append(result, c) + } + } + return result +} + +func (r *TakafulRepository) AddDistribution(d models.SurplusDistribution) { + r.mu.Lock() + defer r.mu.Unlock() + r.distributions = append(r.distributions, d) +} + +func (r *TakafulRepository) GetDistributions(fundID string) []models.SurplusDistribution { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.SurplusDistribution + for _, d := range r.distributions { + if d.FundID == fundID { + result = append(result, d) + } + } + return result +} + +func (r *TakafulRepository) AddCompliance(c models.ShariaCompliance) { + r.mu.Lock() + defer r.mu.Unlock() + r.compliance = append(r.compliance, c) +} + +func (r *TakafulRepository) GetCompliance(fundID string) []models.ShariaCompliance { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.ShariaCompliance + for _, c := range r.compliance { + if c.FundID == fundID { + result = append(result, c) + } + } + return result +} diff --git a/takaful-module/internal/service/service.go b/takaful-module/internal/service/service.go new file mode 100644 index 000000000..87febeef8 --- /dev/null +++ b/takaful-module/internal/service/service.go @@ -0,0 +1,167 @@ +package service + +import ( + "fmt" + "math" + "takaful-module/internal/models" + "takaful-module/internal/repository" + "time" +) + +type TakafulService struct { + repo *repository.TakafulRepository +} + +func NewTakafulService(repo *repository.TakafulRepository) *TakafulService { + return &TakafulService{repo: repo} +} + +type JoinRequest struct { + FundID string `json:"fund_id"` + CustomerID string `json:"customer_id"` + Name string `json:"name"` + ContributionAmt float64 `json:"contribution_amount"` +} + +func (s *TakafulService) JoinFund(req JoinRequest) (*models.TakafulParticipant, error) { + fund, err := s.repo.GetFund(req.FundID) + if err != nil { + return nil, err + } + if !fund.IsActive { + return nil, fmt.Errorf("fund %s is not active", req.FundID) + } + if req.ContributionAmt <= 0 { + return nil, fmt.Errorf("contribution must be positive") + } + + wakalaFee := math.Round(req.ContributionAmt*fund.WakalaFeeRate*100) / 100 + remaining := req.ContributionAmt - wakalaFee + tabarruPortion := math.Round(remaining*0.30*100) / 100 + investPortion := remaining - tabarruPortion + + coverageMultiplier := 10.0 + if fund.FundType == "health" { + coverageMultiplier = 5.0 + } + + participant := &models.TakafulParticipant{ + ID: fmt.Sprintf("TP-%d", time.Now().UnixNano()%10000000), + FundID: req.FundID, + CustomerID: req.CustomerID, + Name: req.Name, + ContributionAmt: req.ContributionAmt, + TabarruPortion: tabarruPortion, + InvestPortion: investPortion, + WakalaFee: wakalaFee, + CoverageAmount: req.ContributionAmt * coverageMultiplier, + Status: "active", + JoinedAt: time.Now(), + } + + if err := s.repo.AddParticipant(participant); err != nil { + return nil, err + } + + fund.TotalContributions += req.ContributionAmt + fund.TabarruPool += tabarruPortion + fund.InvestmentPool += investPortion + fund.ParticipantCount++ + s.repo.UpdateFund(fund) + + s.repo.AddContribution(models.TakafulContribution{ + ID: fmt.Sprintf("TC-%d", time.Now().UnixNano()%10000000), + ParticipantID: participant.ID, + FundID: req.FundID, + Amount: req.ContributionAmt, + Type: models.Tabarru, + TabarruAmount: tabarruPortion, + InvestAmount: investPortion, + WakalaAmount: wakalaFee, + Period: time.Now().Format("2006-01"), + CreatedAt: time.Now(), + }) + + return participant, nil +} + +func (s *TakafulService) DistributeSurplus(fundID, period string) (*models.SurplusDistribution, error) { + fund, err := s.repo.GetFund(fundID) + if err != nil { + return nil, err + } + if fund.SurplusAmount <= 0 { + return nil, fmt.Errorf("no surplus to distribute in fund %s", fundID) + } + + participants := s.repo.ListParticipants(fundID) + if len(participants) == 0 { + return nil, fmt.Errorf("no participants in fund %s", fundID) + } + + distributePct := 0.70 + distributeAmt := math.Round(fund.SurplusAmount*distributePct*100) / 100 + retainedAmt := fund.SurplusAmount - distributeAmt + perCapita := math.Round(distributeAmt/float64(len(participants))*100) / 100 + + for i := range participants { + participants[i].SurplusShare += perCapita + } + + dist := models.SurplusDistribution{ + ID: fmt.Sprintf("SD-%d", time.Now().UnixNano()%10000000), + FundID: fundID, + Period: period, + TotalSurplus: fund.SurplusAmount, + DistributedAmt: distributeAmt, + RetainedAmt: retainedAmt, + ParticipantCnt: len(participants), + PerCapitaShare: perCapita, + DistributedAt: time.Now(), + } + s.repo.AddDistribution(dist) + + fund.SurplusAmount = retainedAmt + s.repo.UpdateFund(fund) + + return &dist, nil +} + +func (s *TakafulService) RunComplianceCheck(fundID string) (*models.ShariaCompliance, error) { + fund, err := s.repo.GetFund(fundID) + if err != nil { + return nil, err + } + + status := "compliant" + details := "All Sharia compliance checks passed" + + if fund.WakalaFeeRate > 0.15 { + status = "warning" + details = "Wakala fee rate exceeds recommended 15% threshold" + } + if fund.ClaimsPaid > fund.TabarruPool*0.9 { + status = "attention" + details = "Tabarru pool near depletion - claims paid exceed 90% of pool" + } + + check := models.ShariaCompliance{ + ID: fmt.Sprintf("SC-%d", time.Now().UnixNano()%10000000), + FundID: fundID, + CheckType: "quarterly_audit", + Status: status, + Details: details, + Auditor: "Sharia Advisory Board", + CheckedAt: time.Now(), + } + s.repo.AddCompliance(check) + return &check, nil +} + +func (s *TakafulService) GetFunds() []models.TakafulFund { return s.repo.GetFunds() } +func (s *TakafulService) GetFund(id string) (*models.TakafulFund, error) { return s.repo.GetFund(id) } +func (s *TakafulService) GetParticipant(id string) (*models.TakafulParticipant, error) { return s.repo.GetParticipant(id) } +func (s *TakafulService) ListParticipants(fundID string) []models.TakafulParticipant { return s.repo.ListParticipants(fundID) } +func (s *TakafulService) GetContributions(participantID string) []models.TakafulContribution { return s.repo.GetContributions(participantID) } +func (s *TakafulService) GetDistributions(fundID string) []models.SurplusDistribution { return s.repo.GetDistributions(fundID) } +func (s *TakafulService) GetCompliance(fundID string) []models.ShariaCompliance { return s.repo.GetCompliance(fundID) } diff --git a/usage-based-insurance/cmd/server/main.go b/usage-based-insurance/cmd/server/main.go index 291df74c7..34c4dd91b 100644 --- a/usage-based-insurance/cmd/server/main.go +++ b/usage-based-insurance/cmd/server/main.go @@ -1,13 +1,14 @@ package main import ( - "encoding/json" "fmt" "log" - "math" "net/http" "os" - "time" + + "usage-based-insurance/internal/handlers" + "usage-based-insurance/internal/repository" + "usage-based-insurance/internal/service" ) func main() { @@ -15,135 +16,22 @@ func main() { if port == "" { port = "8097" } + + repo := repository.NewUBIRepository() + svc := service.NewUBIService(repo) + handler := handlers.NewHandler(svc) + mux := http.NewServeMux() - mux.HandleFunc("/api/v1/ubi/policies", handleUBIPolicies) - mux.HandleFunc("/api/v1/ubi/telematics", handleTelematics) - mux.HandleFunc("/api/v1/ubi/driving-score", handleDrivingScore) - mux.HandleFunc("/api/v1/ubi/premium-adjust", handlePremiumAdjust) + handler.RegisterRoutes(mux) + mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - w.Write([]byte(`{"status":"healthy","service":"usage-based-insurance"}`)) + w.Write([]byte(`{"status":"healthy","service":"usage-based-insurance","version":"2.0.0"}`)) }) - log.Printf("Usage-Based Insurance starting on port %s", port) + + log.Printf("Usage-Based Insurance Service v2.0 starting on port %s", port) if err := http.ListenAndServe(fmt.Sprintf(":%s", port), mux); err != nil { log.Fatal(err) } } - -// TelematicsData from OBD-II or phone GPS -type TelematicsData struct { - PolicyID string `json:"policy_id"` - Timestamp time.Time `json:"timestamp"` - Latitude float64 `json:"latitude"` - Longitude float64 `json:"longitude"` - Speed float64 `json:"speed_kmh"` - Acceleration float64 `json:"acceleration_ms2"` - Braking float64 `json:"braking_ms2"` - CorneringForce float64 `json:"cornering_force_g"` - DistanceKm float64 `json:"distance_km"` - TimeOfDay string `json:"time_of_day"` // day, night, rush_hour - RoadType string `json:"road_type"` // highway, urban, rural -} - -// DrivingScore represents an aggregated driving behavior score -type DrivingScore struct { - PolicyID string `json:"policy_id"` - OverallScore float64 `json:"overall_score"` - SpeedScore float64 `json:"speed_score"` - BrakingScore float64 `json:"braking_score"` - AccelerationScore float64 `json:"acceleration_score"` - CorneringScore float64 `json:"cornering_score"` - TimeOfDayScore float64 `json:"time_of_day_score"` - DistanceRisk float64 `json:"distance_risk_score"` - TotalDistanceKm float64 `json:"total_distance_km"` - TripCount int `json:"trip_count"` - PremiumDiscount float64 `json:"premium_discount_pct"` - RiskCategory string `json:"risk_category"` // low, medium, high - Period string `json:"period"` -} - -func handleUBIPolicies(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "products": []map[string]interface{}{ - { - "id": "UBI-MOTOR-001", - "name": "Pay-Per-Kilometer Motor", - "type": "motor", - "base_rate": "N3/km", - "min_monthly": 2000, - "max_monthly": 25000, - "data_source": "Phone GPS or OBD-II device", - "description": "Pay only for the kilometers you drive. Safe drivers get up to 40% discount.", - }, - { - "id": "UBI-HEALTH-001", - "name": "Active Health Rewards", - "type": "health", - "base_rate": "N5,000/month", - "min_monthly": 3000, - "max_monthly": 8000, - "data_source": "Fitness tracker / Phone pedometer", - "description": "Hit your daily step goal and earn premium discounts. 10,000 steps = 20% off.", - }, - { - "id": "UBI-DEVICE-001", - "name": "Active Device Cover", - "type": "device", - "base_rate": "N10/day (active days only)", - "min_monthly": 0, - "max_monthly": 300, - "data_source": "Device activity detection", - "description": "Only pay for days your device is actively used. Inactive days = no charge.", - }, - }, - }) -} - -func handleTelematics(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) - return - } - var data TelematicsData - json.NewDecoder(r.Body).Decode(&data) - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "status": "recorded", - "trip_id": fmt.Sprintf("TRIP-%d", time.Now().UnixNano()%1000000), - "distance": data.DistanceKm, - "charge": math.Round(data.DistanceKm*3*100) / 100, // N3/km - }) -} - -func handleDrivingScore(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(DrivingScore{ - PolicyID: "UBI-POL-001", - OverallScore: 82.5, - SpeedScore: 85.0, - BrakingScore: 78.0, - AccelerationScore: 88.0, - CorneringScore: 80.0, - TimeOfDayScore: 90.0, - DistanceRisk: 75.0, - TotalDistanceKm: 1250.5, - TripCount: 45, - PremiumDiscount: 25.0, - RiskCategory: "low", - Period: "2026-05", - }) -} - -func handlePremiumAdjust(w http.ResponseWriter, r *http.Request) { - w.Header().Set("Content-Type", "application/json") - json.NewEncoder(w).Encode(map[string]interface{}{ - "policy_id": "UBI-POL-001", - "base_premium": 5000, - "usage_charge": 3751.50, - "safe_driving_discount": -937.88, - "adjusted_premium": 7813.62, - "savings_vs_traditional": "38%", - "next_review": "2026-06-01", - }) -} diff --git a/usage-based-insurance/go.mod b/usage-based-insurance/go.mod index f33475d95..cbe0906e5 100644 --- a/usage-based-insurance/go.mod +++ b/usage-based-insurance/go.mod @@ -1,3 +1,3 @@ -module github.com/munisp/ngapp/usage-based-insurance +module usage-based-insurance go 1.22.0 diff --git a/usage-based-insurance/internal/handlers/handlers.go b/usage-based-insurance/internal/handlers/handlers.go new file mode 100644 index 000000000..fae412054 --- /dev/null +++ b/usage-based-insurance/internal/handlers/handlers.go @@ -0,0 +1,132 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + "strings" + "usage-based-insurance/internal/models" + "usage-based-insurance/internal/service" +) + +type Handler struct { + svc *service.UBIService +} + +func NewHandler(svc *service.UBIService) *Handler { + return &Handler{svc: svc} +} + +func (h *Handler) RegisterRoutes(mux *http.ServeMux) { + mux.HandleFunc("/api/v1/ubi/register", h.Register) + mux.HandleFunc("/api/v1/ubi/policy/", h.GetPolicy) + mux.HandleFunc("/api/v1/ubi/policies", h.ListPolicies) + mux.HandleFunc("/api/v1/ubi/telemetry", h.IngestTelemetry) + mux.HandleFunc("/api/v1/ubi/telemetry/", h.GetTelemetry) + mux.HandleFunc("/api/v1/ubi/score/", h.CalculateScore) + mux.HandleFunc("/api/v1/ubi/scores/", h.GetScores) + mux.HandleFunc("/api/v1/ubi/trips/", h.GetTrips) + mux.HandleFunc("/api/v1/ubi/stats", h.GetStats) +} + +func respondJSON(w http.ResponseWriter, status int, data interface{}) { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + json.NewEncoder(w).Encode(data) +} + +func respondError(w http.ResponseWriter, status int, msg string) { + respondJSON(w, status, map[string]string{"error": msg}) +} + +func (h *Handler) Register(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + var req service.RegisterRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request") + return + } + p, err := h.svc.RegisterPolicy(req) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusCreated, p) +} + +func (h *Handler) GetPolicy(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/ubi/policy/") + p, err := h.svc.GetPolicy(id) + if err != nil { + respondError(w, http.StatusNotFound, err.Error()) + return + } + respondJSON(w, http.StatusOK, p) +} + +func (h *Handler) ListPolicies(w http.ResponseWriter, r *http.Request) { + policies := h.svc.ListPolicies() + respondJSON(w, http.StatusOK, map[string]interface{}{"policies": policies}) +} + +func (h *Handler) IngestTelemetry(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + var data models.TelematicsData + if err := json.NewDecoder(r.Body).Decode(&data); err != nil { + respondError(w, http.StatusBadRequest, "Invalid request") + return + } + if err := h.svc.IngestTelemetry(data.PolicyID, data); err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusCreated, map[string]string{"status": "ingested"}) +} + +func (h *Handler) GetTelemetry(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/ubi/telemetry/") + limit := 100 + if l := r.URL.Query().Get("limit"); l != "" { + if v, err := strconv.Atoi(l); err == nil { + limit = v + } + } + data := h.svc.GetTelemetry(id, limit) + respondJSON(w, http.StatusOK, map[string]interface{}{"telemetry": data}) +} + +func (h *Handler) CalculateScore(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + respondError(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + id := strings.TrimPrefix(r.URL.Path, "/api/v1/ubi/score/") + score, err := h.svc.CalculateScore(id) + if err != nil { + respondError(w, http.StatusBadRequest, err.Error()) + return + } + respondJSON(w, http.StatusOK, score) +} + +func (h *Handler) GetScores(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/ubi/scores/") + scores := h.svc.GetScores(id) + respondJSON(w, http.StatusOK, map[string]interface{}{"scores": scores}) +} + +func (h *Handler) GetTrips(w http.ResponseWriter, r *http.Request) { + id := strings.TrimPrefix(r.URL.Path, "/api/v1/ubi/trips/") + trips := h.svc.GetTrips(id) + respondJSON(w, http.StatusOK, map[string]interface{}{"trips": trips}) +} + +func (h *Handler) GetStats(w http.ResponseWriter, r *http.Request) { + respondJSON(w, http.StatusOK, h.svc.GetStats()) +} diff --git a/usage-based-insurance/internal/models/ubi.go b/usage-based-insurance/internal/models/ubi.go new file mode 100644 index 000000000..5c54a5dd5 --- /dev/null +++ b/usage-based-insurance/internal/models/ubi.go @@ -0,0 +1,74 @@ +package models + +import "time" + +type TelematicsData struct { + ID string `json:"id"` + PolicyID string `json:"policy_id"` + DeviceID string `json:"device_id"` + Timestamp time.Time `json:"timestamp"` + Speed float64 `json:"speed_kmh"` + Acceleration float64 `json:"acceleration"` + Braking float64 `json:"braking_force"` + Cornering float64 `json:"cornering_force"` + DistanceKm float64 `json:"distance_km"` + FuelConsumption float64 `json:"fuel_consumption_l"` + EngineRPM int `json:"engine_rpm"` + Location GeoPoint `json:"location"` + IsNightDriving bool `json:"is_night_driving"` + RoadType string `json:"road_type"` + WeatherCondition string `json:"weather_condition"` +} + +type GeoPoint struct { + Lat float64 `json:"lat"` + Lng float64 `json:"lng"` +} + +type DrivingScore struct { + ID string `json:"id"` + PolicyID string `json:"policy_id"` + Period string `json:"period"` + OverallScore float64 `json:"overall_score"` + SpeedScore float64 `json:"speed_score"` + BrakingScore float64 `json:"braking_score"` + AccelScore float64 `json:"acceleration_score"` + CorneringScore float64 `json:"cornering_score"` + NightDrivingPct float64 `json:"night_driving_pct"` + TotalDistanceKm float64 `json:"total_distance_km"` + TotalTrips int `json:"total_trips"` + HardBrakeEvents int `json:"hard_brake_events"` + SpeedingEvents int `json:"speeding_events"` + PremiumDiscount float64 `json:"premium_discount_pct"` + RiskCategory string `json:"risk_category"` + CalculatedAt time.Time `json:"calculated_at"` +} + +type UBIPolicy struct { + ID string `json:"id"` + CustomerID string `json:"customer_id"` + VehicleReg string `json:"vehicle_reg"` + VehicleMake string `json:"vehicle_make"` + VehicleModel string `json:"vehicle_model"` + VehicleYear int `json:"vehicle_year"` + DeviceID string `json:"device_id"` + BasePremium float64 `json:"base_premium"` + AdjustedPremium float64 `json:"adjusted_premium"` + CurrentDiscount float64 `json:"current_discount_pct"` + Status string `json:"status"` + LastScoreDate *time.Time `json:"last_score_date,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +type Trip struct { + ID string `json:"id"` + PolicyID string `json:"policy_id"` + StartTime time.Time `json:"start_time"` + EndTime time.Time `json:"end_time"` + DistanceKm float64 `json:"distance_km"` + DurationMin float64 `json:"duration_min"` + AvgSpeed float64 `json:"avg_speed_kmh"` + MaxSpeed float64 `json:"max_speed_kmh"` + Score float64 `json:"trip_score"` + Events int `json:"safety_events"` +} diff --git a/usage-based-insurance/internal/repository/repository.go b/usage-based-insurance/internal/repository/repository.go new file mode 100644 index 000000000..29092c710 --- /dev/null +++ b/usage-based-insurance/internal/repository/repository.go @@ -0,0 +1,128 @@ +package repository + +import ( + "fmt" + "sync" + "time" + "usage-based-insurance/internal/models" +) + +type UBIRepository struct { + mu sync.RWMutex + policies map[string]*models.UBIPolicy + telemetry map[string][]models.TelematicsData + scores map[string][]models.DrivingScore + trips map[string][]models.Trip +} + +func NewUBIRepository() *UBIRepository { + return &UBIRepository{ + policies: make(map[string]*models.UBIPolicy), + telemetry: make(map[string][]models.TelematicsData), + scores: make(map[string][]models.DrivingScore), + trips: make(map[string][]models.Trip), + } +} + +func (r *UBIRepository) CreatePolicy(p *models.UBIPolicy) error { + r.mu.Lock() + defer r.mu.Unlock() + r.policies[p.ID] = p + return nil +} + +func (r *UBIRepository) GetPolicy(id string) (*models.UBIPolicy, error) { + r.mu.RLock() + defer r.mu.RUnlock() + p, ok := r.policies[id] + if !ok { + return nil, fmt.Errorf("policy %s not found", id) + } + return p, nil +} + +func (r *UBIRepository) UpdatePolicy(p *models.UBIPolicy) { + r.mu.Lock() + defer r.mu.Unlock() + r.policies[p.ID] = p +} + +func (r *UBIRepository) ListPolicies() []models.UBIPolicy { + r.mu.RLock() + defer r.mu.RUnlock() + var result []models.UBIPolicy + for _, p := range r.policies { + result = append(result, *p) + } + return result +} + +func (r *UBIRepository) AddTelemetry(policyID string, data models.TelematicsData) { + r.mu.Lock() + defer r.mu.Unlock() + r.telemetry[policyID] = append(r.telemetry[policyID], data) +} + +func (r *UBIRepository) GetTelemetry(policyID string, limit int) []models.TelematicsData { + r.mu.RLock() + defer r.mu.RUnlock() + data := r.telemetry[policyID] + if limit > 0 && len(data) > limit { + return data[len(data)-limit:] + } + return data +} + +func (r *UBIRepository) SaveScore(score models.DrivingScore) { + r.mu.Lock() + defer r.mu.Unlock() + r.scores[score.PolicyID] = append(r.scores[score.PolicyID], score) +} + +func (r *UBIRepository) GetScores(policyID string) []models.DrivingScore { + r.mu.RLock() + defer r.mu.RUnlock() + return r.scores[policyID] +} + +func (r *UBIRepository) AddTrip(trip models.Trip) { + r.mu.Lock() + defer r.mu.Unlock() + r.trips[trip.PolicyID] = append(r.trips[trip.PolicyID], trip) +} + +func (r *UBIRepository) GetTrips(policyID string) []models.Trip { + r.mu.RLock() + defer r.mu.RUnlock() + return r.trips[policyID] +} + +func (r *UBIRepository) GetStats() map[string]interface{} { + r.mu.RLock() + defer r.mu.RUnlock() + totalTrips := 0 + totalDistance := 0.0 + for _, trips := range r.trips { + totalTrips += len(trips) + for _, t := range trips { + totalDistance += t.DistanceKm + } + } + return map[string]interface{}{ + "total_policies": len(r.policies), + "total_trips": totalTrips, + "total_distance_km": totalDistance, + "total_telemetry_points": func() int { + c := 0 + for _, d := range r.telemetry { + c += len(d) + } + return c + }(), + } +} + +func init() { + _ = time.Now + _ = fmt.Sprintf +} diff --git a/usage-based-insurance/internal/service/service.go b/usage-based-insurance/internal/service/service.go new file mode 100644 index 000000000..df9dfcdd1 --- /dev/null +++ b/usage-based-insurance/internal/service/service.go @@ -0,0 +1,209 @@ +package service + +import ( + "fmt" + "math" + "time" + "usage-based-insurance/internal/models" + "usage-based-insurance/internal/repository" +) + +type UBIService struct { + repo *repository.UBIRepository +} + +func NewUBIService(repo *repository.UBIRepository) *UBIService { + return &UBIService{repo: repo} +} + +type RegisterRequest struct { + CustomerID string `json:"customer_id"` + VehicleReg string `json:"vehicle_reg"` + VehicleMake string `json:"vehicle_make"` + VehicleModel string `json:"vehicle_model"` + VehicleYear int `json:"vehicle_year"` + BasePremium float64 `json:"base_premium"` +} + +func (s *UBIService) RegisterPolicy(req RegisterRequest) (*models.UBIPolicy, error) { + if req.VehicleReg == "" { + return nil, fmt.Errorf("vehicle registration is required") + } + if req.BasePremium <= 0 { + return nil, fmt.Errorf("base premium must be positive") + } + + policy := &models.UBIPolicy{ + ID: fmt.Sprintf("UBI-%d", time.Now().UnixNano()%10000000), + CustomerID: req.CustomerID, + VehicleReg: req.VehicleReg, + VehicleMake: req.VehicleMake, + VehicleModel: req.VehicleModel, + VehicleYear: req.VehicleYear, + DeviceID: fmt.Sprintf("OBD-%d", time.Now().UnixNano()%1000000), + BasePremium: req.BasePremium, + AdjustedPremium: req.BasePremium, + CurrentDiscount: 0, + Status: "active", + CreatedAt: time.Now(), + } + + if err := s.repo.CreatePolicy(policy); err != nil { + return nil, err + } + return policy, nil +} + +func (s *UBIService) IngestTelemetry(policyID string, data models.TelematicsData) error { + _, err := s.repo.GetPolicy(policyID) + if err != nil { + return err + } + data.ID = fmt.Sprintf("TEL-%d", time.Now().UnixNano()%10000000) + data.PolicyID = policyID + if data.Timestamp.IsZero() { + data.Timestamp = time.Now() + } + + hour := data.Timestamp.Hour() + data.IsNightDriving = hour < 5 || hour >= 22 + + s.repo.AddTelemetry(policyID, data) + return nil +} + +func (s *UBIService) CalculateScore(policyID string) (*models.DrivingScore, error) { + policy, err := s.repo.GetPolicy(policyID) + if err != nil { + return nil, err + } + + telemetry := s.repo.GetTelemetry(policyID, 0) + if len(telemetry) == 0 { + return nil, fmt.Errorf("no telemetry data for policy %s", policyID) + } + + speedScore := 100.0 + brakingScore := 100.0 + accelScore := 100.0 + corneringScore := 100.0 + hardBrakes := 0 + speedingEvents := 0 + nightPoints := 0 + totalDist := 0.0 + + for _, t := range telemetry { + if t.Speed > 120 { + speedScore -= 5 + speedingEvents++ + } else if t.Speed > 100 { + speedScore -= 2 + } + if t.Braking > 8.0 { + brakingScore -= 10 + hardBrakes++ + } else if t.Braking > 5.0 { + brakingScore -= 3 + } + if t.Acceleration > 5.0 { + accelScore -= 5 + } + if t.Cornering > 4.0 { + corneringScore -= 5 + } + if t.IsNightDriving { + nightPoints++ + } + totalDist += t.DistanceKm + } + + clamp := func(v float64) float64 { + if v < 0 { return 0 } + if v > 100 { return 100 } + return v + } + speedScore = clamp(speedScore) + brakingScore = clamp(brakingScore) + accelScore = clamp(accelScore) + corneringScore = clamp(corneringScore) + + overall := speedScore*0.3 + brakingScore*0.3 + accelScore*0.2 + corneringScore*0.2 + nightPct := float64(nightPoints) / float64(len(telemetry)) * 100 + + discount := 0.0 + riskCat := "standard" + switch { + case overall >= 90: + discount = 25.0 + riskCat = "excellent" + case overall >= 75: + discount = 15.0 + riskCat = "good" + case overall >= 60: + discount = 5.0 + riskCat = "average" + case overall >= 40: + discount = 0 + riskCat = "poor" + default: + discount = -15.0 + riskCat = "high_risk" + } + + if nightPct > 30 { + discount -= 5 + } + + now := time.Now() + score := models.DrivingScore{ + ID: fmt.Sprintf("SCR-%d", time.Now().UnixNano()%10000000), + PolicyID: policyID, + Period: now.Format("2006-01"), + OverallScore: math.Round(overall*10) / 10, + SpeedScore: math.Round(speedScore*10) / 10, + BrakingScore: math.Round(brakingScore*10) / 10, + AccelScore: math.Round(accelScore*10) / 10, + CorneringScore: math.Round(corneringScore*10) / 10, + NightDrivingPct: math.Round(nightPct*10) / 10, + TotalDistanceKm: math.Round(totalDist*10) / 10, + TotalTrips: len(s.repo.GetTrips(policyID)), + HardBrakeEvents: hardBrakes, + SpeedingEvents: speedingEvents, + PremiumDiscount: discount, + RiskCategory: riskCat, + CalculatedAt: now, + } + + s.repo.SaveScore(score) + + policy.AdjustedPremium = policy.BasePremium * (1 - discount/100) + policy.CurrentDiscount = discount + policy.LastScoreDate = &now + s.repo.UpdatePolicy(policy) + + return &score, nil +} + +func (s *UBIService) GetPolicy(id string) (*models.UBIPolicy, error) { + return s.repo.GetPolicy(id) +} + +func (s *UBIService) ListPolicies() []models.UBIPolicy { + return s.repo.ListPolicies() +} + +func (s *UBIService) GetScores(policyID string) []models.DrivingScore { + return s.repo.GetScores(policyID) +} + +func (s *UBIService) GetTelemetry(policyID string, limit int) []models.TelematicsData { + return s.repo.GetTelemetry(policyID, limit) +} + +func (s *UBIService) GetTrips(policyID string) []models.Trip { + return s.repo.GetTrips(policyID) +} + +func (s *UBIService) GetStats() map[string]interface{} { + return s.repo.GetStats() +}