package httpapi import ( "encoding/json" "fmt" "net/http" "strconv" "time" "spin/internal/incentive" "spin/internal/models" "github.com/go-chi/chi/v5" ) // ---- config --------------------------------------------------------------- // incentiveConfig loads (or defaults) the IncentiveConfig for a year and adapts // it to the engine's plain Config. func (s *Server) incentiveConfig(year int) (models.IncentiveConfig, incentive.Config) { var c models.IncentiveConfig if err := s.db.Where("year = ?", year).First(&c).Error; err != nil { c = models.IncentiveConfig{Year: year, PointRate: 1_000_000, DepositPct: 30, MiddlePct: 40, FinalPct: 30, NonBECompanyPct: 60, NonBEPartnerPct: 40, RankQuota: defaultQuota()} } return c, toEngineConfig(c) } func defaultQuota() map[string]interface{} { return map[string]interface{}{ models.RankIntern: 15.0, models.RankJunior: 30.0, models.RankSenior: 50.0, models.RankLead: 80.0, models.RankPartner: 120.0, } } func toEngineConfig(c models.IncentiveConfig) incentive.Config { quota := map[string]float64{} for k, v := range c.RankQuota { if f, ok := toFloat(v); ok { quota[k] = f } } return incentive.Config{ PointRate: c.PointRate, DepositPct: c.DepositPct, MiddlePct: c.MiddlePct, FinalPct: c.FinalPct, NonBECompanyPct: c.NonBECompanyPct, NonBEPartnerPct: c.NonBEPartnerPct, RankQuota: quota, } } func toFloat(v interface{}) (float64, bool) { switch n := v.(type) { case float64: return n, true case float32: return float64(n), true case int: return float64(n), true case int64: return float64(n), true case json.Number: // GORM's datatypes.JSONMap decodes JSON numbers as json.Number. f, err := n.Float64() return f, err == nil case string: f, err := strconv.ParseFloat(n, 64) return f, err == nil } return 0, false } func yearParam(r *http.Request) int { if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil && y > 0 { return y } return time.Now().Year() } func (s *Server) handleGetIncentiveConfig(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } c, _ := s.incentiveConfig(yearParam(r)) writeJSON(w, http.StatusOK, c) } func (s *Server) handlePutIncentiveConfig(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var in models.IncentiveConfig if err := decodeJSON(r, &in); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } if in.Year == 0 { in.Year = time.Now().Year() } var existing models.IncentiveConfig if err := s.db.Where("year = ?", in.Year).First(&existing).Error; err == nil { in.ID = existing.ID s.db.Save(&in) } else { s.db.Create(&in) } s.audit(r, "update", "incentive_config", strconv.Itoa(in.Year), "") writeJSON(w, http.StatusOK, in) } // ---- recompute (rebuild stages + allocations from contract) --------------- func (s *Server) projectMemberPortions(projectID string) []incentive.MemberPortion { var pms []models.ProjectMember s.db.Where("project_id = ?", projectID).Find(&pms) out := make([]incentive.MemberPortion, 0, len(pms)) for _, pm := range pms { isPartner := false if m := s.lookupMember(pm.MemberEmail); m != nil { isPartner = m.IsPartner || m.Rank == models.RankPartner } out = append(out, incentive.MemberPortion{Email: pm.MemberEmail, Portion: pm.Portion, IsPartner: isPartner}) } return out } func (s *Server) handleRecomputeProject(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } pid := chi.URLParam(r, "id") var contract models.Contract if err := s.db.First(&contract, "project_id = ?", pid).Error; err != nil { writeError(w, http.StatusBadRequest, "계약 정보(BE/계약금액)를 먼저 입력하세요") return } year := yearParam(r) _, eng := s.incentiveConfig(year) stages := incentive.ComputeStages(contract.TotalAmount, contract.BEAmount, eng) // upsert PaymentStage rows (keep status/dates on existing kind×scope). for _, st := range stages { var existing models.PaymentStage err := s.db.Where("project_id = ? AND kind = ? AND scope = ?", pid, st.Kind, st.Scope).First(&existing).Error if err != nil { s.db.Create(&models.PaymentStage{ProjectID: pid, Kind: st.Kind, Scope: st.Scope, Amount: st.Amount, Pct: st.Pct, Status: models.FixPlanned}) } else { existing.Amount = st.Amount existing.Pct = st.Pct s.db.Save(&existing) } } // rebuild non-override UserIncentive rows. members := s.projectMemberPortions(pid) allocs := incentive.ComputeAllocs(stages, members, eng) s.db.Where("project_id = ? AND \"override\" = ?", pid, false).Delete(&models.UserIncentive{}) var stageRows []models.PaymentStage s.db.Where("project_id = ?", pid).Find(&stageRows) stageID := map[string]string{} stageStatus := map[string]string{} for _, sr := range stageRows { stageID[sr.Kind+"|"+sr.Scope] = sr.ID stageStatus[sr.Kind+"|"+sr.Scope] = sr.Status } for _, a := range allocs { key := a.Kind + "|" + a.Scope // skip member+stage that already has an override row var cnt int64 s.db.Model(&models.UserIncentive{}). Where("project_id = ? AND lower(member_email) = lower(?) AND kind = ? AND scope = ? AND \"override\" = ?", pid, a.Email, a.Kind, a.Scope, true).Count(&cnt) if cnt > 0 { continue } s.db.Create(&models.UserIncentive{ProjectID: pid, MemberEmail: a.Email, StageID: stageID[key], Kind: a.Kind, Scope: a.Scope, Year: year, Portion: a.Portion, Amount: a.Amount, Points: a.Points, FixStatus: stageStatus[key]}) } s.audit(r, "recompute", "incentive", pid, "") writeJSON(w, http.StatusOK, map[string]interface{}{"stages": len(stages), "allocs": len(allocs)}) } func (s *Server) handleListStages(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var out []models.PaymentStage q := s.db.Order("scope asc, kind asc") if pid := r.URL.Query().Get("projectId"); pid != "" { q = q.Where("project_id = ?", pid) } q.Find(&out) writeJSON(w, http.StatusOK, out) } // handleSetStageStatus toggles a stage's fix lifecycle and propagates the status // to its non-override UserIncentive rows (반영중 → 반영완료 등). func (s *Server) handleSetStageStatus(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var st models.PaymentStage if err := s.db.First(&st, "id = ?", chi.URLParam(r, "stId")).Error; err != nil { writeError(w, http.StatusNotFound, "단계를 찾을 수 없습니다") return } var body struct { Status string `json:"status"` FixedDate string `json:"fixedDate"` } decodeJSON(r, &body) st.Status = body.Status if body.FixedDate != "" { st.FixedDate = body.FixedDate } s.db.Save(&st) // propagate to non-override allocations now := time.Now() updates := map[string]interface{}{"fix_status": body.Status} if body.Status == models.FixApplied { updates["applied_at"] = &now } if body.Status == models.FixPaid { updates["paid_at"] = &now } s.db.Model(&models.UserIncentive{}). Where("stage_id = ? AND \"override\" = ?", st.ID, false).Updates(updates) s.audit(r, "stage_status", "payment_stage", st.ID, body.Status) // notify affected members on 반영완료/지급완료 (BE/non-BE 등 내부 개념은 노출하지 않음) if body.Status == models.FixApplied || body.Status == models.FixPaid { msg := "인센티브 포인트가 반영되었습니다." if body.Status == models.FixPaid { msg = "인센티브가 지급되었습니다." } var emails []string s.db.Model(&models.UserIncentive{}).Where("stage_id = ?", st.ID).Distinct().Pluck("member_email", &emails) for _, e := range emails { s.notify(e, "incentive", "인센티브 업데이트", msg, "/incentive") } } writeJSON(w, http.StatusOK, st) } // ---- user incentives ------------------------------------------------------ func (s *Server) handleListUserIncentives(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } q := s.db.Order("created_at desc") if pid := r.URL.Query().Get("projectId"); pid != "" { q = q.Where("project_id = ?", pid) } if em := lc(r.URL.Query().Get("email")); em != "" { q = q.Where("lower(member_email) = ?", em) } var out []models.UserIncentive q.Find(&out) writeJSON(w, http.StatusOK, out) } // handlePatchUserIncentive is the custom-override entry point: any field can be // hand-edited and the row is flagged Override so recompute won't clobber it. func (s *Server) handlePatchUserIncentive(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var ui models.UserIncentive if err := s.db.First(&ui, "id = ?", chi.URLParam(r, "uiId")).Error; err != nil { writeError(w, http.StatusNotFound, "내역을 찾을 수 없습니다") return } var patch map[string]interface{} if err := decodeJSON(r, &patch); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } delete(patch, "id") patch["override"] = true // any manual edit pins the row s.db.Model(&ui).Updates(patch) s.db.First(&ui, "id = ?", ui.ID) s.audit(r, "override", "user_incentive", ui.ID, "") writeJSON(w, http.StatusOK, ui) } // ---- member dashboard ----------------------------------------------------- type myIncentive struct { Year int `json:"year"` Rank string `json:"rank"` Quota float64 `json:"quota"` PointsTotal float64 `json:"pointsTotal"` // all points (any status) PointsApplied float64 `json:"pointsApplied"` // applied+paid ExcessPoints float64 `json:"excessPoints"` PointRate float64 `json:"pointRate"` EstPayout float64 `json:"estPayout"` Items []models.UserIncentive `json:"items"` ByProject map[string]float64 `json:"byProject"` } func (s *Server) handleMyIncentive(w http.ResponseWriter, r *http.Request) { email := s.email(r) if s.isAdmin(r) { if q := lc(r.URL.Query().Get("email")); q != "" { email = q } } year := yearParam(r) cfg, eng := s.incentiveConfig(year) var items []models.UserIncentive s.db.Where("lower(member_email) = ? AND year = ?", email, year).Order("created_at desc").Find(&items) total, applied := 0.0, 0.0 byProject := map[string]float64{} for _, it := range items { total += it.Points if it.FixStatus == models.FixApplied || it.FixStatus == models.FixPaid { applied += it.Points } byProject[it.ProjectID] += it.Points } rank := "" if m := s.lookupMember(email); m != nil { rank = m.Rank } st := incentive.ComputeSettlement(email, rank, applied, 0, eng) writeJSON(w, http.StatusOK, myIncentive{ Year: year, Rank: rank, Quota: st.Quota, PointsTotal: total, PointsApplied: applied, ExcessPoints: st.ExcessPoints, PointRate: cfg.PointRate, EstPayout: st.PayoutAmount, Items: items, ByProject: byProject, }) } // ---- quarterly settlement ------------------------------------------------- func (s *Server) handleListSettlements(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } q := s.db.Order("year desc, quarter desc") if y, err := strconv.Atoi(r.URL.Query().Get("year")); err == nil && y > 0 { q = q.Where("year = ?", y) } var out []models.QuarterlySettlement q.Find(&out) writeJSON(w, http.StatusOK, out) } // handleRunSettlement computes (or refreshes) settlement rows for a year+quarter. func (s *Server) handleRunSettlement(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var body struct { Year int `json:"year"` Quarter int `json:"quarter"` } decodeJSON(r, &body) if body.Year == 0 { body.Year = time.Now().Year() } if body.Quarter == 0 { body.Quarter = (int(time.Now().Month())-1)/3 + 1 } _, eng := s.incentiveConfig(body.Year) // applied points per member up to this quarter (cumulative within the year). var members []models.Member s.db.Find(&members) var results []models.QuarterlySettlement for _, m := range members { var applied float64 s.db.Model(&models.UserIncentive{}). Where("lower(member_email) = lower(?) AND year = ? AND (fix_status = ? OR fix_status = ?)", m.Email, body.Year, models.FixApplied, models.FixPaid). Select("COALESCE(SUM(points),0)").Scan(&applied) // prior paid this year var paidYTD float64 s.db.Model(&models.QuarterlySettlement{}). Where("lower(member_email) = lower(?) AND year = ? AND quarter < ? AND fixed = ?", m.Email, body.Year, body.Quarter, true). Select("COALESCE(SUM(payout_points),0)").Scan(&paidYTD) st := incentive.ComputeSettlement(m.Email, m.Rank, applied, paidYTD, eng) if st.PointsCumul == 0 && st.PayoutPoints == 0 { continue } var existing models.QuarterlySettlement row := models.QuarterlySettlement{MemberEmail: m.Email, Year: body.Year, Quarter: body.Quarter, Rank: m.Rank, Quota: st.Quota, PointsCumul: st.PointsCumul, ExcessPoints: st.ExcessPoints, PaidPointsYTD: st.PaidPointsYTD, PayoutPoints: st.PayoutPoints, PayoutAmount: st.PayoutAmount} if err := s.db.Where("lower(member_email)=lower(?) AND year=? AND quarter=?", m.Email, body.Year, body.Quarter).First(&existing).Error; err == nil { if !existing.Fixed { // never overwrite a fixed row row.ID = existing.ID s.db.Save(&row) } row = existing } else { s.db.Create(&row) } results = append(results, row) } s.audit(r, "settlement_run", "settlement", strconv.Itoa(body.Year)+"Q"+strconv.Itoa(body.Quarter), "") writeJSON(w, http.StatusOK, results) } // handleFixSettlement locks a settlement row (그 차익이 급여로 확정 지급). func (s *Server) handleFixSettlement(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var st models.QuarterlySettlement if err := s.db.First(&st, "id = ?", chi.URLParam(r, "sId")).Error; err != nil { writeError(w, http.StatusNotFound, "정산을 찾을 수 없습니다") return } now := time.Now() st.Fixed = true st.FixedAt = &now s.db.Save(&st) s.audit(r, "settlement_fix", "settlement", st.ID, "") if st.PayoutPoints > 0 { s.notify(st.MemberEmail, "settlement", fmt.Sprintf("%d년 %d분기 인센티브 정산 확정", st.Year, st.Quarter), "이번 분기 인센티브가 확정되었습니다. 내 인센티브에서 확인하세요.", "/incentive") } writeJSON(w, http.StatusOK, st) } // ---- simulation (pure, no persistence) ------------------------------------ type simRequest struct { Year int `json:"year"` Total float64 `json:"total"` BE float64 `json:"be"` Members []incentive.MemberPortion `json:"members"` Config *incentive.Config `json:"config"` // optional overrides } func (s *Server) handleSimulate(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var req simRequest if err := decodeJSON(r, &req); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } _, eng := s.incentiveConfig(yearParam(r)) if req.Config != nil { eng = *req.Config if eng.PointRate == 0 { eng.PointRate = 1 } } stages := incentive.ComputeStages(req.Total, req.BE, eng) allocs := incentive.ComputeAllocs(stages, req.Members, eng) // per-member point totals byMember := map[string]float64{} for _, a := range allocs { byMember[a.Email] += a.Points } writeJSON(w, http.StatusOK, map[string]interface{}{ "stages": stages, "allocs": allocs, "byMember": byMember, }) }