package httpapi import ( "net/http" "strconv" "time" "spin/internal/models" "github.com/go-chi/chi/v5" ) // All accounting is admin-only (전사 재무 데이터). func (s *Server) handleListAccounts(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var out []models.Account s.db.Order("code asc").Find(&out) writeJSON(w, http.StatusOK, out) } func (s *Server) handleCreateAccount(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var a models.Account if err := decodeJSON(r, &a); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } s.db.Create(&a) writeJSON(w, http.StatusCreated, a) } func (s *Server) handleListTransactions(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } q := s.db.Order("date desc, created_at desc") if k := r.URL.Query().Get("kind"); k != "" { q = q.Where("kind = ?", k) } if pid := r.URL.Query().Get("projectId"); pid != "" { q = q.Where("project_id = ?", pid) } if from := r.URL.Query().Get("from"); from != "" { q = q.Where("date >= ?", from) } if to := r.URL.Query().Get("to"); to != "" { q = q.Where("date <= ?", to) } var out []models.Transaction q.Limit(1000).Find(&out) writeJSON(w, http.StatusOK, out) } func (s *Server) handleCreateTransaction(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var t models.Transaction if err := decodeJSON(r, &t); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } t.CreatedBy = currentUser(r.Context()).Email s.db.Create(&t) s.audit(r, "create", "transaction", t.ID, t.Kind) writeJSON(w, http.StatusCreated, t) } func (s *Server) handlePatchTransaction(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var t models.Transaction if err := s.db.First(&t, "id = ?", chi.URLParam(r, "txId")).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") s.db.Model(&t).Updates(patch) s.db.First(&t, "id = ?", t.ID) writeJSON(w, http.StatusOK, t) } func (s *Server) handleDeleteTransaction(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } s.db.Delete(&models.Transaction{}, "id = ?", chi.URLParam(r, "txId")) writeJSON(w, http.StatusOK, map[string]bool{"ok": true}) } func (s *Server) handleListTaxes(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var out []models.TaxRecord s.db.Order("due_date desc").Find(&out) writeJSON(w, http.StatusOK, out) } func (s *Server) handleCreateTax(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var t models.TaxRecord if err := decodeJSON(r, &t); err != nil { writeError(w, http.StatusBadRequest, err.Error()) return } s.db.Create(&t) writeJSON(w, http.StatusCreated, t) } func (s *Server) handlePatchTax(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } var t models.TaxRecord if err := s.db.First(&t, "id = ?", chi.URLParam(r, "taxId")).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") s.db.Model(&t).Updates(patch) s.db.First(&t, "id = ?", t.ID) writeJSON(w, http.StatusOK, t) } // ---- summary: cashflow vs incentive gap ----------------------------------- type acctSummary struct { Year int `json:"year"` CashIn float64 `json:"cashIn"` CashOut float64 `json:"cashOut"` Net float64 `json:"net"` IncentiveApplied float64 `json:"incentiveApplied"` // KRW value of applied points IncentivePaid float64 `json:"incentivePaid"` // actually disbursed (지급완료) Gap float64 `json:"gap"` // applied − paid (미지급 부채성) Monthly []monthlyPL `json:"monthly"` ByKind map[string]float64 `json:"byKind"` } type monthlyPL struct { Month string `json:"month"` Income float64 `json:"income"` Expense float64 `json:"expense"` Net float64 `json:"net"` } func (s *Server) handleAccountingSummary(w http.ResponseWriter, r *http.Request) { if !s.requireAdmin(w, r) { return } year := yearParam(r) prefix := strconv.Itoa(year) var txns []models.Transaction s.db.Where("date LIKE ?", prefix+"%").Find(&txns) sum := acctSummary{Year: year, ByKind: map[string]float64{}} monthly := map[string]*monthlyPL{} for m := 1; m <= 12; m++ { key := prefix + "-" + pad2(m) monthly[key] = &monthlyPL{Month: key} } for _, t := range txns { sum.ByKind[t.Kind] += t.Amount mk := t.Date if len(mk) >= 7 { mk = mk[:7] } row := monthly[mk] if row == nil { row = &monthlyPL{Month: mk} monthly[mk] = row } if t.Kind == models.TxnIncome { sum.CashIn += t.Amount row.Income += t.Amount } else { out := t.Amount if out < 0 { out = -out } sum.CashOut += out row.Expense += out } row.Net = row.Income - row.Expense } sum.Net = sum.CashIn - sum.CashOut // incentive applied (반영완료/지급완료) point value vs actually paid (지급완료) var appliedPts, paidPts float64 s.db.Model(&models.UserIncentive{}).Where("year = ? AND (fix_status = ? OR fix_status = ?)", year, models.FixApplied, models.FixPaid).Select("COALESCE(SUM(points),0)").Scan(&appliedPts) s.db.Model(&models.UserIncentive{}).Where("year = ? AND fix_status = ?", year, models.FixPaid).Select("COALESCE(SUM(points),0)").Scan(&paidPts) _, eng := s.incentiveConfig(year) sum.IncentiveApplied = appliedPts * eng.PointRate sum.IncentivePaid = paidPts * eng.PointRate sum.Gap = sum.IncentiveApplied - sum.IncentivePaid // ordered monthly slice for m := 1; m <= 12; m++ { key := prefix + "-" + pad2(m) sum.Monthly = append(sum.Monthly, *monthly[key]) } writeJSON(w, http.StatusOK, sum) } var _ = time.Now