Database: time_entries, billing_rates, invoices tables with RLS.
Backend: CRUD services+handlers for time entries, billing rates, invoices.
- Time entries: list/create/update/delete, summary by case/user/month
- Billing rates: upsert with auto-close previous, current rate lookup
- Invoices: create with auto-number (RE-YYYY-NNN), status transitions
(draft->sent->paid, cancellation), link time entries on invoice create
API: 11 new endpoints under /api/time-entries, /api/billing-rates, /api/invoices
Frontend: Zeiterfassung tab on case detail, /abrechnung overview with filters,
/abrechnung/rechnungen list+detail with status actions, billing rates settings
Also: resolved merge conflicts between audit-trail and role-based branches,
added missing types (Notification, AuditLogResponse, NotificationPreferences)
171 lines
4.1 KiB
Go
171 lines
4.1 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/auth"
|
|
"mgit.msbls.de/m/KanzlAI-mGMT/internal/services"
|
|
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
type InvoiceHandler struct {
|
|
svc *services.InvoiceService
|
|
}
|
|
|
|
func NewInvoiceHandler(svc *services.InvoiceService) *InvoiceHandler {
|
|
return &InvoiceHandler{svc: svc}
|
|
}
|
|
|
|
// List handles GET /api/invoices?case_id=&status=
|
|
func (h *InvoiceHandler) List(w http.ResponseWriter, r *http.Request) {
|
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusForbidden, "missing tenant")
|
|
return
|
|
}
|
|
|
|
var caseID *uuid.UUID
|
|
if caseStr := r.URL.Query().Get("case_id"); caseStr != "" {
|
|
parsed, err := uuid.Parse(caseStr)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid case_id")
|
|
return
|
|
}
|
|
caseID = &parsed
|
|
}
|
|
|
|
invoices, err := h.svc.List(r.Context(), tenantID, caseID, r.URL.Query().Get("status"))
|
|
if err != nil {
|
|
internalError(w, "failed to list invoices", err)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, map[string]any{"invoices": invoices})
|
|
}
|
|
|
|
// Get handles GET /api/invoices/{id}
|
|
func (h *InvoiceHandler) Get(w http.ResponseWriter, r *http.Request) {
|
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusForbidden, "missing tenant")
|
|
return
|
|
}
|
|
|
|
invoiceID, err := parsePathUUID(r, "id")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid invoice ID")
|
|
return
|
|
}
|
|
|
|
inv, err := h.svc.GetByID(r.Context(), tenantID, invoiceID)
|
|
if err != nil {
|
|
internalError(w, "failed to get invoice", err)
|
|
return
|
|
}
|
|
if inv == nil {
|
|
writeError(w, http.StatusNotFound, "invoice not found")
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, inv)
|
|
}
|
|
|
|
// Create handles POST /api/invoices
|
|
func (h *InvoiceHandler) Create(w http.ResponseWriter, r *http.Request) {
|
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusForbidden, "missing tenant")
|
|
return
|
|
}
|
|
userID, _ := auth.UserFromContext(r.Context())
|
|
|
|
var input services.CreateInvoiceInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
|
|
if input.ClientName == "" {
|
|
writeError(w, http.StatusBadRequest, "client_name is required")
|
|
return
|
|
}
|
|
if input.CaseID == uuid.Nil {
|
|
writeError(w, http.StatusBadRequest, "case_id is required")
|
|
return
|
|
}
|
|
|
|
inv, err := h.svc.Create(r.Context(), tenantID, userID, input)
|
|
if err != nil {
|
|
internalError(w, "failed to create invoice", err)
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusCreated, inv)
|
|
}
|
|
|
|
// Update handles PUT /api/invoices/{id}
|
|
func (h *InvoiceHandler) Update(w http.ResponseWriter, r *http.Request) {
|
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusForbidden, "missing tenant")
|
|
return
|
|
}
|
|
|
|
invoiceID, err := parsePathUUID(r, "id")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid invoice ID")
|
|
return
|
|
}
|
|
|
|
var input services.UpdateInvoiceInput
|
|
if err := json.NewDecoder(r.Body).Decode(&input); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
|
|
inv, err := h.svc.Update(r.Context(), tenantID, invoiceID, input)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, inv)
|
|
}
|
|
|
|
// UpdateStatus handles PATCH /api/invoices/{id}/status
|
|
func (h *InvoiceHandler) UpdateStatus(w http.ResponseWriter, r *http.Request) {
|
|
tenantID, ok := auth.TenantFromContext(r.Context())
|
|
if !ok {
|
|
writeError(w, http.StatusForbidden, "missing tenant")
|
|
return
|
|
}
|
|
|
|
invoiceID, err := parsePathUUID(r, "id")
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid invoice ID")
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
|
|
writeError(w, http.StatusBadRequest, "invalid JSON body")
|
|
return
|
|
}
|
|
if body.Status == "" {
|
|
writeError(w, http.StatusBadRequest, "status is required")
|
|
return
|
|
}
|
|
|
|
inv, err := h.svc.UpdateStatus(r.Context(), tenantID, invoiceID, body.Status)
|
|
if err != nil {
|
|
writeError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
|
|
writeJSON(w, http.StatusOK, inv)
|
|
}
|