diff --git a/backend/internal/auth/context.go b/backend/internal/auth/context.go index c2aeaef..9553104 100644 --- a/backend/internal/auth/context.go +++ b/backend/internal/auth/context.go @@ -9,19 +9,11 @@ import ( type contextKey string const ( -<<<<<<< HEAD userIDKey contextKey = "user_id" tenantIDKey contextKey = "tenant_id" + userRoleKey contextKey = "user_role" ipKey contextKey = "ip_address" userAgentKey contextKey = "user_agent" -||||||| 82878df - userIDKey contextKey = "user_id" - tenantIDKey contextKey = "tenant_id" -======= - userIDKey contextKey = "user_id" - tenantIDKey contextKey = "tenant_id" - userRoleKey contextKey = "user_role" ->>>>>>> mai/pike/p0-role-based ) func ContextWithUserID(ctx context.Context, userID uuid.UUID) context.Context { @@ -41,7 +33,15 @@ func TenantFromContext(ctx context.Context) (uuid.UUID, bool) { id, ok := ctx.Value(tenantIDKey).(uuid.UUID) return id, ok } -<<<<<<< HEAD + +func ContextWithUserRole(ctx context.Context, role string) context.Context { + return context.WithValue(ctx, userRoleKey, role) +} + +func UserRoleFromContext(ctx context.Context) string { + role, _ := ctx.Value(userRoleKey).(string) + return role +} func ContextWithRequestInfo(ctx context.Context, ip, userAgent string) context.Context { ctx = context.WithValue(ctx, ipKey, ip) @@ -62,15 +62,3 @@ func UserAgentFromContext(ctx context.Context) *string { } return nil } -||||||| 82878df -======= - -func ContextWithUserRole(ctx context.Context, role string) context.Context { - return context.WithValue(ctx, userRoleKey, role) -} - -func UserRoleFromContext(ctx context.Context) string { - role, _ := ctx.Value(userRoleKey).(string) - return role -} ->>>>>>> mai/pike/p0-role-based diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go index 02428c6..fa25c98 100644 --- a/backend/internal/auth/middleware.go +++ b/backend/internal/auth/middleware.go @@ -35,36 +35,6 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler { } ctx := ContextWithUserID(r.Context(), userID) -<<<<<<< HEAD - // Tenant resolution is handled by TenantResolver middleware for scoped routes. - // Tenant management routes handle their own access control. -||||||| 82878df - - // Resolve tenant and role from user_tenants - var membership struct { - TenantID uuid.UUID `db:"tenant_id"` - Role string `db:"role"` - } - err = m.db.GetContext(r.Context(), &membership, - "SELECT tenant_id, role FROM user_tenants WHERE user_id = $1 LIMIT 1", userID) - if err != nil { - http.Error(w, "no tenant found for user", http.StatusForbidden) - return - } - ctx = ContextWithTenantID(ctx, membership.TenantID) - ctx = ContextWithUserRole(ctx, membership.Role) - -======= - - // Resolve tenant from user_tenants - var tenantID uuid.UUID - err = m.db.GetContext(r.Context(), &tenantID, - "SELECT tenant_id FROM user_tenants WHERE user_id = $1 LIMIT 1", userID) - if err != nil { - http.Error(w, "no tenant found for user", http.StatusForbidden) - return - } - ctx = ContextWithTenantID(ctx, tenantID) // Capture IP and user-agent for audit logging ip := r.Header.Get("X-Forwarded-For") @@ -73,7 +43,9 @@ func (m *Middleware) RequireAuth(next http.Handler) http.Handler { } ctx = ContextWithRequestInfo(ctx, ip, r.UserAgent()) ->>>>>>> mai/knuth/p0-audit-trail-append + // Tenant resolution is handled by TenantResolver middleware for scoped routes. + // Tenant management routes handle their own access control. + next.ServeHTTP(w, r.WithContext(ctx)) }) } diff --git a/backend/internal/auth/tenant_resolver.go b/backend/internal/auth/tenant_resolver.go index 0dab57f..9cb1a54 100644 --- a/backend/internal/auth/tenant_resolver.go +++ b/backend/internal/auth/tenant_resolver.go @@ -12,12 +12,8 @@ import ( // Defined as an interface to avoid circular dependency with services. type TenantLookup interface { FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) -<<<<<<< HEAD VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) -||||||| 82878df -======= GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) ->>>>>>> mai/pike/p0-role-based } // TenantResolver is middleware that resolves the tenant from X-Tenant-ID header @@ -46,38 +42,23 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler { http.Error(w, `{"error":"invalid X-Tenant-ID"}`, http.StatusBadRequest) return } -<<<<<<< HEAD - // Verify user has access to this tenant - hasAccess, err := tr.lookup.VerifyAccess(r.Context(), userID, parsed) + // Verify user has access and get their role + role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed) if err != nil { slog.Error("tenant access check failed", "error", err, "user_id", userID, "tenant_id", parsed) http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) return } - if !hasAccess { + if role == "" { http.Error(w, `{"error":"no access to tenant"}`, http.StatusForbidden) return } -||||||| 82878df -======= - // Verify user has access and get their role - role, err := tr.lookup.GetUserRole(r.Context(), userID, parsed) - if err != nil { - http.Error(w, "error checking tenant access", http.StatusInternalServerError) - return - } - if role == "" { - http.Error(w, "no access to this tenant", http.StatusForbidden) - return - } ->>>>>>> mai/pike/p0-role-based tenantID = parsed - // Override the role from middleware with the correct one for this tenant r = r.WithContext(ContextWithUserRole(r.Context(), role)) } else { - // Default to user's first tenant (role already set by middleware) + // Default to user's first tenant first, err := tr.lookup.FirstTenantForUser(r.Context(), userID) if err != nil { slog.Error("failed to resolve default tenant", "error", err, "user_id", userID) @@ -89,6 +70,15 @@ func (tr *TenantResolver) Resolve(next http.Handler) http.Handler { return } tenantID = *first + + // Also resolve role for default tenant + role, err := tr.lookup.GetUserRole(r.Context(), userID, tenantID) + if err != nil { + slog.Error("failed to get role for default tenant", "error", err, "user_id", userID, "tenant_id", tenantID) + http.Error(w, `{"error":"internal error"}`, http.StatusInternalServerError) + return + } + r = r.WithContext(ContextWithUserRole(r.Context(), role)) } ctx := ContextWithTenantID(r.Context(), tenantID) diff --git a/backend/internal/auth/tenant_resolver_test.go b/backend/internal/auth/tenant_resolver_test.go index d0300c2..2b09ac8 100644 --- a/backend/internal/auth/tenant_resolver_test.go +++ b/backend/internal/auth/tenant_resolver_test.go @@ -10,49 +10,34 @@ import ( ) type mockTenantLookup struct { -<<<<<<< HEAD tenantID *uuid.UUID err error hasAccess bool accessErr error -||||||| 82878df - tenantID *uuid.UUID - err error -======= - tenantID *uuid.UUID - role string - err error ->>>>>>> mai/pike/p0-role-based + role string } func (m *mockTenantLookup) FirstTenantForUser(ctx context.Context, userID uuid.UUID) (*uuid.UUID, error) { return m.tenantID, m.err } -<<<<<<< HEAD func (m *mockTenantLookup) VerifyAccess(ctx context.Context, userID, tenantID uuid.UUID) (bool, error) { return m.hasAccess, m.accessErr } -||||||| 82878df -======= func (m *mockTenantLookup) GetUserRole(ctx context.Context, userID, tenantID uuid.UUID) (string, error) { if m.role != "" { return m.role, m.err } - return "associate", m.err + if m.hasAccess { + return "associate", m.err + } + return "", m.err } ->>>>>>> mai/pike/p0-role-based func TestTenantResolver_FromHeader(t *testing.T) { tenantID := uuid.New() -<<<<<<< HEAD - tr := NewTenantResolver(&mockTenantLookup{hasAccess: true}) -||||||| 82878df - tr := NewTenantResolver(&mockTenantLookup{}) -======= - tr := NewTenantResolver(&mockTenantLookup{role: "partner"}) ->>>>>>> mai/pike/p0-role-based + tr := NewTenantResolver(&mockTenantLookup{hasAccess: true, role: "partner"}) var gotTenantID uuid.UUID next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -101,7 +86,7 @@ func TestTenantResolver_FromHeader_NoAccess(t *testing.T) { func TestTenantResolver_DefaultsToFirst(t *testing.T) { tenantID := uuid.New() - tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID}) + tr := NewTenantResolver(&mockTenantLookup{tenantID: &tenantID, role: "owner"}) var gotTenantID uuid.UUID next := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/backend/internal/handlers/billing_rates.go b/backend/internal/handlers/billing_rates.go new file mode 100644 index 0000000..bb2acf6 --- /dev/null +++ b/backend/internal/handlers/billing_rates.go @@ -0,0 +1,66 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" +) + +type BillingRateHandler struct { + svc *services.BillingRateService +} + +func NewBillingRateHandler(svc *services.BillingRateService) *BillingRateHandler { + return &BillingRateHandler{svc: svc} +} + +// List handles GET /api/billing-rates +func (h *BillingRateHandler) List(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + rates, err := h.svc.List(r.Context(), tenantID) + if err != nil { + internalError(w, "failed to list billing rates", err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"billing_rates": rates}) +} + +// Upsert handles PUT /api/billing-rates +func (h *BillingRateHandler) Upsert(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + var input services.UpsertBillingRateInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + + if input.Rate < 0 { + writeError(w, http.StatusBadRequest, "rate must be non-negative") + return + } + if input.ValidFrom == "" { + writeError(w, http.StatusBadRequest, "valid_from is required") + return + } + + rate, err := h.svc.Upsert(r.Context(), tenantID, input) + if err != nil { + internalError(w, "failed to upsert billing rate", err) + return + } + + writeJSON(w, http.StatusOK, rate) +} diff --git a/backend/internal/handlers/deadlines.go b/backend/internal/handlers/deadlines.go index e1a2a39..26bd5ae 100644 --- a/backend/internal/handlers/deadlines.go +++ b/backend/internal/handlers/deadlines.go @@ -198,18 +198,9 @@ func (h *DeadlineHandlers) Delete(w http.ResponseWriter, r *http.Request) { return } -<<<<<<< HEAD - if err := h.deadlines.Delete(tenantID, deadlineID); err != nil { - writeError(w, http.StatusNotFound, "deadline not found") -||||||| 82878df - err = h.deadlines.Delete(tenantID, deadlineID) - if err != nil { - writeError(w, http.StatusNotFound, err.Error()) -======= err = h.deadlines.Delete(r.Context(), tenantID, deadlineID) if err != nil { writeError(w, http.StatusNotFound, err.Error()) ->>>>>>> mai/knuth/p0-audit-trail-append return } diff --git a/backend/internal/handlers/invoices.go b/backend/internal/handlers/invoices.go new file mode 100644 index 0000000..456612d --- /dev/null +++ b/backend/internal/handlers/invoices.go @@ -0,0 +1,170 @@ +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) +} diff --git a/backend/internal/handlers/time_entries.go b/backend/internal/handlers/time_entries.go new file mode 100644 index 0000000..4393aaf --- /dev/null +++ b/backend/internal/handlers/time_entries.go @@ -0,0 +1,209 @@ +package handlers + +import ( + "encoding/json" + "net/http" + "strconv" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/auth" + "mgit.msbls.de/m/KanzlAI-mGMT/internal/services" + + "github.com/google/uuid" +) + +type TimeEntryHandler struct { + svc *services.TimeEntryService +} + +func NewTimeEntryHandler(svc *services.TimeEntryService) *TimeEntryHandler { + return &TimeEntryHandler{svc: svc} +} + +// ListForCase handles GET /api/cases/{id}/time-entries +func (h *TimeEntryHandler) ListForCase(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + caseID, err := parsePathUUID(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid case ID") + return + } + + entries, err := h.svc.ListForCase(r.Context(), tenantID, caseID) + if err != nil { + internalError(w, "failed to list time entries", err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"time_entries": entries}) +} + +// List handles GET /api/time-entries?case_id=&user_id=&from=&to= +func (h *TimeEntryHandler) List(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + limit, _ := strconv.Atoi(r.URL.Query().Get("limit")) + offset, _ := strconv.Atoi(r.URL.Query().Get("offset")) + limit, offset = clampPagination(limit, offset) + + filter := services.TimeEntryFilter{ + From: r.URL.Query().Get("from"), + To: r.URL.Query().Get("to"), + Limit: limit, + Offset: offset, + } + + if caseStr := r.URL.Query().Get("case_id"); caseStr != "" { + caseID, err := uuid.Parse(caseStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid case_id") + return + } + filter.CaseID = &caseID + } + if userStr := r.URL.Query().Get("user_id"); userStr != "" { + userID, err := uuid.Parse(userStr) + if err != nil { + writeError(w, http.StatusBadRequest, "invalid user_id") + return + } + filter.UserID = &userID + } + + entries, total, err := h.svc.List(r.Context(), tenantID, filter) + if err != nil { + internalError(w, "failed to list time entries", err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{ + "time_entries": entries, + "total": total, + }) +} + +// Create handles POST /api/cases/{id}/time-entries +func (h *TimeEntryHandler) 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()) + + caseID, err := parsePathUUID(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid case ID") + return + } + + var input services.CreateTimeEntryInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + input.CaseID = caseID + + if input.Description == "" { + writeError(w, http.StatusBadRequest, "description is required") + return + } + if input.DurationMinutes <= 0 { + writeError(w, http.StatusBadRequest, "duration_minutes must be positive") + return + } + if input.Date == "" { + writeError(w, http.StatusBadRequest, "date is required") + return + } + + entry, err := h.svc.Create(r.Context(), tenantID, userID, input) + if err != nil { + internalError(w, "failed to create time entry", err) + return + } + + writeJSON(w, http.StatusCreated, entry) +} + +// Update handles PUT /api/time-entries/{id} +func (h *TimeEntryHandler) Update(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + entryID, err := parsePathUUID(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid time entry ID") + return + } + + var input services.UpdateTimeEntryInput + if err := json.NewDecoder(r.Body).Decode(&input); err != nil { + writeError(w, http.StatusBadRequest, "invalid JSON body") + return + } + + entry, err := h.svc.Update(r.Context(), tenantID, entryID, input) + if err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, entry) +} + +// Delete handles DELETE /api/time-entries/{id} +func (h *TimeEntryHandler) Delete(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + entryID, err := parsePathUUID(r, "id") + if err != nil { + writeError(w, http.StatusBadRequest, "invalid time entry ID") + return + } + + if err := h.svc.Delete(r.Context(), tenantID, entryID); err != nil { + writeError(w, http.StatusBadRequest, err.Error()) + return + } + + writeJSON(w, http.StatusOK, map[string]string{"status": "deleted"}) +} + +// Summary handles GET /api/time-entries/summary?group_by=case|user|month&from=&to= +func (h *TimeEntryHandler) Summary(w http.ResponseWriter, r *http.Request) { + tenantID, ok := auth.TenantFromContext(r.Context()) + if !ok { + writeError(w, http.StatusForbidden, "missing tenant") + return + } + + groupBy := r.URL.Query().Get("group_by") + if groupBy == "" { + groupBy = "case" + } + + summaries, err := h.svc.Summary(r.Context(), tenantID, groupBy, + r.URL.Query().Get("from"), r.URL.Query().Get("to")) + if err != nil { + internalError(w, "failed to get summary", err) + return + } + + writeJSON(w, http.StatusOK, map[string]any{"summary": summaries}) +} diff --git a/backend/internal/models/billing_rate.go b/backend/internal/models/billing_rate.go new file mode 100644 index 0000000..bdf3088 --- /dev/null +++ b/backend/internal/models/billing_rate.go @@ -0,0 +1,18 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type BillingRate struct { + ID uuid.UUID `db:"id" json:"id"` + TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"` + UserID *uuid.UUID `db:"user_id" json:"user_id,omitempty"` + Rate float64 `db:"rate" json:"rate"` + Currency string `db:"currency" json:"currency"` + ValidFrom string `db:"valid_from" json:"valid_from"` + ValidTo *string `db:"valid_to" json:"valid_to,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` +} diff --git a/backend/internal/models/invoice.go b/backend/internal/models/invoice.go new file mode 100644 index 0000000..ad6beab --- /dev/null +++ b/backend/internal/models/invoice.go @@ -0,0 +1,38 @@ +package models + +import ( + "encoding/json" + "time" + + "github.com/google/uuid" +) + +type Invoice struct { + ID uuid.UUID `db:"id" json:"id"` + TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"` + CaseID uuid.UUID `db:"case_id" json:"case_id"` + InvoiceNumber string `db:"invoice_number" json:"invoice_number"` + ClientName string `db:"client_name" json:"client_name"` + ClientAddress *string `db:"client_address" json:"client_address,omitempty"` + Items json.RawMessage `db:"items" json:"items"` + Subtotal float64 `db:"subtotal" json:"subtotal"` + TaxRate float64 `db:"tax_rate" json:"tax_rate"` + TaxAmount float64 `db:"tax_amount" json:"tax_amount"` + Total float64 `db:"total" json:"total"` + Status string `db:"status" json:"status"` + IssuedAt *string `db:"issued_at" json:"issued_at,omitempty"` + DueAt *string `db:"due_at" json:"due_at,omitempty"` + PaidAt *time.Time `db:"paid_at" json:"paid_at,omitempty"` + Notes *string `db:"notes" json:"notes,omitempty"` + CreatedBy uuid.UUID `db:"created_by" json:"created_by"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +type InvoiceItem struct { + Description string `json:"description"` + DurationMinutes int `json:"duration_minutes,omitempty"` + HourlyRate float64 `json:"hourly_rate,omitempty"` + Amount float64 `json:"amount"` + TimeEntryID *string `json:"time_entry_id,omitempty"` +} diff --git a/backend/internal/models/time_entry.go b/backend/internal/models/time_entry.go new file mode 100644 index 0000000..50f5448 --- /dev/null +++ b/backend/internal/models/time_entry.go @@ -0,0 +1,24 @@ +package models + +import ( + "time" + + "github.com/google/uuid" +) + +type TimeEntry struct { + ID uuid.UUID `db:"id" json:"id"` + TenantID uuid.UUID `db:"tenant_id" json:"tenant_id"` + CaseID uuid.UUID `db:"case_id" json:"case_id"` + UserID uuid.UUID `db:"user_id" json:"user_id"` + Date string `db:"date" json:"date"` + DurationMinutes int `db:"duration_minutes" json:"duration_minutes"` + Description string `db:"description" json:"description"` + Activity *string `db:"activity" json:"activity,omitempty"` + Billable bool `db:"billable" json:"billable"` + Billed bool `db:"billed" json:"billed"` + InvoiceID *uuid.UUID `db:"invoice_id" json:"invoice_id,omitempty"` + HourlyRate *float64 `db:"hourly_rate" json:"hourly_rate,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go index 84b7f8e..8273acd 100644 --- a/backend/internal/router/router.go +++ b/backend/internal/router/router.go @@ -29,14 +29,11 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se deadlineRuleSvc := services.NewDeadlineRuleService(db) calculator := services.NewDeadlineCalculator(holidaySvc) storageCli := services.NewStorageClient(cfg.SupabaseURL, cfg.SupabaseServiceKey) -<<<<<<< HEAD documentSvc := services.NewDocumentService(db, storageCli, auditSvc) -||||||| 82878df - documentSvc := services.NewDocumentService(db, storageCli) -======= - documentSvc := services.NewDocumentService(db, storageCli) assignmentSvc := services.NewCaseAssignmentService(db) ->>>>>>> mai/pike/p0-role-based + timeEntrySvc := services.NewTimeEntryService(db, auditSvc) + billingRateSvc := services.NewBillingRateService(db, auditSvc) + invoiceSvc := services.NewInvoiceService(db, auditSvc) // AI service (optional — only if API key is configured) var aiH *handlers.AIHandler @@ -71,6 +68,9 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se eventH := handlers.NewCaseEventHandler(db) docH := handlers.NewDocumentHandler(documentSvc) assignmentH := handlers.NewCaseAssignmentHandler(assignmentSvc) + timeEntryH := handlers.NewTimeEntryHandler(timeEntrySvc) + billingRateH := handlers.NewBillingRateHandler(billingRateSvc) + invoiceH := handlers.NewInvoiceHandler(invoiceSvc) // Public routes mux.HandleFunc("GET /health", handleHealth(db)) @@ -112,7 +112,7 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se scoped.HandleFunc("GET /api/cases", caseH.List) scoped.HandleFunc("POST /api/cases", perm(auth.PermCreateCase, caseH.Create)) scoped.HandleFunc("GET /api/cases/{id}", caseH.Get) - scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update) // case-level access checked in handler + scoped.HandleFunc("PUT /api/cases/{id}", caseH.Update) scoped.HandleFunc("DELETE /api/cases/{id}", perm(auth.PermCreateCase, caseH.Delete)) // Parties — same access as case editing @@ -162,21 +162,15 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se // Dashboard — all can view scoped.HandleFunc("GET /api/dashboard", dashboardH.Get) -<<<<<<< HEAD - // Audit log - scoped.HandleFunc("GET /api/audit-log", auditH.List) + // Audit log — view requires PermViewAuditLog + scoped.HandleFunc("GET /api/audit-log", perm(auth.PermViewAuditLog, auditH.List)) - // Documents -||||||| 82878df - // Documents -======= // Documents — all can upload, delete checked in handler (own vs all) ->>>>>>> mai/pike/p0-role-based scoped.HandleFunc("GET /api/cases/{id}/documents", docH.ListByCase) scoped.HandleFunc("POST /api/cases/{id}/documents", perm(auth.PermUploadDocuments, docH.Upload)) scoped.HandleFunc("GET /api/documents/{docId}", docH.Download) scoped.HandleFunc("GET /api/documents/{docId}/meta", docH.GetMeta) - scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) // permission check inside handler + scoped.HandleFunc("DELETE /api/documents/{docId}", docH.Delete) // AI endpoints (rate limited: 5 req/min burst 10 per IP) if aiH != nil { @@ -185,7 +179,6 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se scoped.HandleFunc("POST /api/ai/summarize-case", perm(auth.PermAIExtraction, aiLimiter.LimitFunc(aiH.SummarizeCase))) } -<<<<<<< HEAD // Notifications if notifH != nil { scoped.HandleFunc("GET /api/notifications", notifH.List) @@ -196,18 +189,32 @@ func New(db *sqlx.DB, authMW *auth.Middleware, cfg *config.Config, calDAVSvc *se scoped.HandleFunc("PUT /api/notification-preferences", notifH.UpdatePreferences) } - // CalDAV sync endpoints -||||||| 82878df - // CalDAV sync endpoints -======= // CalDAV sync endpoints — settings permission required ->>>>>>> mai/pike/p0-role-based if calDAVSvc != nil { calDAVH := handlers.NewCalDAVHandler(calDAVSvc) scoped.HandleFunc("POST /api/caldav/sync", perm(auth.PermManageSettings, calDAVH.TriggerSync)) scoped.HandleFunc("GET /api/caldav/status", calDAVH.GetStatus) } + // Time entries — billing permission for create/update/delete + scoped.HandleFunc("GET /api/cases/{id}/time-entries", timeEntryH.ListForCase) + scoped.HandleFunc("POST /api/cases/{id}/time-entries", perm(auth.PermManageBilling, timeEntryH.Create)) + scoped.HandleFunc("GET /api/time-entries", timeEntryH.List) + scoped.HandleFunc("GET /api/time-entries/summary", perm(auth.PermManageBilling, timeEntryH.Summary)) + scoped.HandleFunc("PUT /api/time-entries/{id}", perm(auth.PermManageBilling, timeEntryH.Update)) + scoped.HandleFunc("DELETE /api/time-entries/{id}", perm(auth.PermManageBilling, timeEntryH.Delete)) + + // Billing rates — billing permission required + scoped.HandleFunc("GET /api/billing-rates", perm(auth.PermManageBilling, billingRateH.List)) + scoped.HandleFunc("PUT /api/billing-rates", perm(auth.PermManageBilling, billingRateH.Upsert)) + + // Invoices — billing permission required + scoped.HandleFunc("GET /api/invoices", perm(auth.PermManageBilling, invoiceH.List)) + scoped.HandleFunc("POST /api/invoices", perm(auth.PermManageBilling, invoiceH.Create)) + scoped.HandleFunc("GET /api/invoices/{id}", perm(auth.PermManageBilling, invoiceH.Get)) + scoped.HandleFunc("PUT /api/invoices/{id}", perm(auth.PermManageBilling, invoiceH.Update)) + scoped.HandleFunc("PATCH /api/invoices/{id}/status", perm(auth.PermManageBilling, invoiceH.UpdateStatus)) + // Wire: auth -> tenant routes go directly, scoped routes get tenant resolver api.Handle("/api/", tenantResolver.Resolve(scoped)) diff --git a/backend/internal/services/billing_rate_service.go b/backend/internal/services/billing_rate_service.go new file mode 100644 index 0000000..8fd4e3e --- /dev/null +++ b/backend/internal/services/billing_rate_service.go @@ -0,0 +1,88 @@ +package services + +import ( + "context" + "fmt" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" +) + +type BillingRateService struct { + db *sqlx.DB + audit *AuditService +} + +func NewBillingRateService(db *sqlx.DB, audit *AuditService) *BillingRateService { + return &BillingRateService{db: db, audit: audit} +} + +type UpsertBillingRateInput struct { + UserID *uuid.UUID `json:"user_id,omitempty"` + Rate float64 `json:"rate"` + Currency string `json:"currency"` + ValidFrom string `json:"valid_from"` + ValidTo *string `json:"valid_to,omitempty"` +} + +func (s *BillingRateService) List(ctx context.Context, tenantID uuid.UUID) ([]models.BillingRate, error) { + var rates []models.BillingRate + err := s.db.SelectContext(ctx, &rates, + `SELECT id, tenant_id, user_id, rate, currency, valid_from, valid_to, created_at + FROM billing_rates + WHERE tenant_id = $1 + ORDER BY valid_from DESC, user_id NULLS LAST`, + tenantID) + if err != nil { + return nil, fmt.Errorf("list billing rates: %w", err) + } + return rates, nil +} + +func (s *BillingRateService) Upsert(ctx context.Context, tenantID uuid.UUID, input UpsertBillingRateInput) (*models.BillingRate, error) { + if input.Currency == "" { + input.Currency = "EUR" + } + + // Close any existing open-ended rate for this user + _, err := s.db.ExecContext(ctx, + `UPDATE billing_rates SET valid_to = $3 + WHERE tenant_id = $1 + AND (($2::uuid IS NULL AND user_id IS NULL) OR user_id = $2) + AND valid_to IS NULL + AND valid_from < $3`, + tenantID, input.UserID, input.ValidFrom) + if err != nil { + return nil, fmt.Errorf("close existing rate: %w", err) + } + + var rate models.BillingRate + err = s.db.QueryRowxContext(ctx, + `INSERT INTO billing_rates (tenant_id, user_id, rate, currency, valid_from, valid_to) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, tenant_id, user_id, rate, currency, valid_from, valid_to, created_at`, + tenantID, input.UserID, input.Rate, input.Currency, input.ValidFrom, input.ValidTo, + ).StructScan(&rate) + if err != nil { + return nil, fmt.Errorf("upsert billing rate: %w", err) + } + + s.audit.Log(ctx, "create", "billing_rate", &rate.ID, nil, rate) + return &rate, nil +} + +func (s *BillingRateService) GetCurrentRate(ctx context.Context, tenantID uuid.UUID, userID uuid.UUID, date string) (*float64, error) { + var rate float64 + err := s.db.GetContext(ctx, &rate, + `SELECT rate FROM billing_rates + WHERE tenant_id = $1 AND (user_id = $2 OR user_id IS NULL) + AND valid_from <= $3 AND (valid_to IS NULL OR valid_to >= $3) + ORDER BY user_id NULLS LAST LIMIT 1`, + tenantID, userID, date) + if err != nil { + return nil, err + } + return &rate, nil +} diff --git a/backend/internal/services/invoice_service.go b/backend/internal/services/invoice_service.go new file mode 100644 index 0000000..c0b8c8f --- /dev/null +++ b/backend/internal/services/invoice_service.go @@ -0,0 +1,292 @@ +package services + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" +) + +type InvoiceService struct { + db *sqlx.DB + audit *AuditService +} + +func NewInvoiceService(db *sqlx.DB, audit *AuditService) *InvoiceService { + return &InvoiceService{db: db, audit: audit} +} + +type CreateInvoiceInput struct { + CaseID uuid.UUID `json:"case_id"` + ClientName string `json:"client_name"` + ClientAddress *string `json:"client_address,omitempty"` + Items []models.InvoiceItem `json:"items"` + TaxRate *float64 `json:"tax_rate,omitempty"` + IssuedAt *string `json:"issued_at,omitempty"` + DueAt *string `json:"due_at,omitempty"` + Notes *string `json:"notes,omitempty"` + TimeEntryIDs []uuid.UUID `json:"time_entry_ids,omitempty"` +} + +type UpdateInvoiceInput struct { + ClientName *string `json:"client_name,omitempty"` + ClientAddress *string `json:"client_address,omitempty"` + Items []models.InvoiceItem `json:"items,omitempty"` + TaxRate *float64 `json:"tax_rate,omitempty"` + IssuedAt *string `json:"issued_at,omitempty"` + DueAt *string `json:"due_at,omitempty"` + Notes *string `json:"notes,omitempty"` +} + +const invoiceCols = `id, tenant_id, case_id, invoice_number, client_name, client_address, + items, subtotal, tax_rate, tax_amount, total, status, issued_at, due_at, paid_at, notes, + created_by, created_at, updated_at` + +func (s *InvoiceService) List(ctx context.Context, tenantID uuid.UUID, caseID *uuid.UUID, status string) ([]models.Invoice, error) { + where := "WHERE tenant_id = $1" + args := []any{tenantID} + argIdx := 2 + + if caseID != nil { + where += fmt.Sprintf(" AND case_id = $%d", argIdx) + args = append(args, *caseID) + argIdx++ + } + if status != "" { + where += fmt.Sprintf(" AND status = $%d", argIdx) + args = append(args, status) + argIdx++ + } + + var invoices []models.Invoice + err := s.db.SelectContext(ctx, &invoices, + fmt.Sprintf("SELECT %s FROM invoices %s ORDER BY created_at DESC", invoiceCols, where), + args...) + if err != nil { + return nil, fmt.Errorf("list invoices: %w", err) + } + return invoices, nil +} + +func (s *InvoiceService) GetByID(ctx context.Context, tenantID, invoiceID uuid.UUID) (*models.Invoice, error) { + var inv models.Invoice + err := s.db.GetContext(ctx, &inv, + `SELECT `+invoiceCols+` FROM invoices WHERE tenant_id = $1 AND id = $2`, + tenantID, invoiceID) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get invoice: %w", err) + } + return &inv, nil +} + +func (s *InvoiceService) Create(ctx context.Context, tenantID, userID uuid.UUID, input CreateInvoiceInput) (*models.Invoice, error) { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return nil, fmt.Errorf("begin tx: %w", err) + } + defer tx.Rollback() + + // Generate invoice number: RE-YYYY-NNN + year := time.Now().Year() + var seq int + err = tx.GetContext(ctx, &seq, + `SELECT COUNT(*) + 1 FROM invoices WHERE tenant_id = $1 AND invoice_number LIKE $2`, + tenantID, fmt.Sprintf("RE-%d-%%", year)) + if err != nil { + return nil, fmt.Errorf("generate invoice number: %w", err) + } + invoiceNumber := fmt.Sprintf("RE-%d-%03d", year, seq) + + // Calculate totals + taxRate := 19.00 + if input.TaxRate != nil { + taxRate = *input.TaxRate + } + + var subtotal float64 + for _, item := range input.Items { + subtotal += item.Amount + } + taxAmount := subtotal * taxRate / 100 + total := subtotal + taxAmount + + itemsJSON, err := json.Marshal(input.Items) + if err != nil { + return nil, fmt.Errorf("marshal items: %w", err) + } + + var inv models.Invoice + err = tx.QueryRowxContext(ctx, + `INSERT INTO invoices (tenant_id, case_id, invoice_number, client_name, client_address, + items, subtotal, tax_rate, tax_amount, total, issued_at, due_at, notes, created_by) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) + RETURNING `+invoiceCols, + tenantID, input.CaseID, invoiceNumber, input.ClientName, input.ClientAddress, + itemsJSON, subtotal, taxRate, taxAmount, total, input.IssuedAt, input.DueAt, input.Notes, userID, + ).StructScan(&inv) + if err != nil { + return nil, fmt.Errorf("create invoice: %w", err) + } + + // Mark linked time entries as billed + if len(input.TimeEntryIDs) > 0 { + query, args, err := sqlx.In( + `UPDATE time_entries SET billed = true, invoice_id = ? WHERE tenant_id = ? AND id IN (?)`, + inv.ID, tenantID, input.TimeEntryIDs) + if err != nil { + return nil, fmt.Errorf("build time entry update: %w", err) + } + query = tx.Rebind(query) + _, err = tx.ExecContext(ctx, query, args...) + if err != nil { + return nil, fmt.Errorf("mark time entries billed: %w", err) + } + } + + if err := tx.Commit(); err != nil { + return nil, fmt.Errorf("commit: %w", err) + } + + s.audit.Log(ctx, "create", "invoice", &inv.ID, nil, inv) + return &inv, nil +} + +func (s *InvoiceService) Update(ctx context.Context, tenantID, invoiceID uuid.UUID, input UpdateInvoiceInput) (*models.Invoice, error) { + old, err := s.GetByID(ctx, tenantID, invoiceID) + if err != nil { + return nil, err + } + if old == nil { + return nil, fmt.Errorf("invoice not found") + } + if old.Status != "draft" { + return nil, fmt.Errorf("can only update draft invoices") + } + + // Recalculate totals if items changed + var itemsJSON json.RawMessage + var subtotal float64 + taxRate := old.TaxRate + + if input.Items != nil { + for _, item := range input.Items { + subtotal += item.Amount + } + itemsJSON, _ = json.Marshal(input.Items) + } + if input.TaxRate != nil { + taxRate = *input.TaxRate + } + + if input.Items != nil { + taxAmount := subtotal * taxRate / 100 + total := subtotal + taxAmount + + var inv models.Invoice + err = s.db.QueryRowxContext(ctx, + `UPDATE invoices SET + client_name = COALESCE($3, client_name), + client_address = COALESCE($4, client_address), + items = $5, + subtotal = $6, + tax_rate = $7, + tax_amount = $8, + total = $9, + issued_at = COALESCE($10, issued_at), + due_at = COALESCE($11, due_at), + notes = COALESCE($12, notes), + updated_at = now() + WHERE tenant_id = $1 AND id = $2 + RETURNING `+invoiceCols, + tenantID, invoiceID, input.ClientName, input.ClientAddress, + itemsJSON, subtotal, taxRate, subtotal*taxRate/100, total, + input.IssuedAt, input.DueAt, input.Notes, + ).StructScan(&inv) + if err != nil { + return nil, fmt.Errorf("update invoice: %w", err) + } + s.audit.Log(ctx, "update", "invoice", &inv.ID, old, inv) + return &inv, nil + } + + // Update without changing items + var inv models.Invoice + err = s.db.QueryRowxContext(ctx, + `UPDATE invoices SET + client_name = COALESCE($3, client_name), + client_address = COALESCE($4, client_address), + tax_rate = COALESCE($5, tax_rate), + issued_at = COALESCE($6, issued_at), + due_at = COALESCE($7, due_at), + notes = COALESCE($8, notes), + updated_at = now() + WHERE tenant_id = $1 AND id = $2 + RETURNING `+invoiceCols, + tenantID, invoiceID, input.ClientName, input.ClientAddress, + input.TaxRate, input.IssuedAt, input.DueAt, input.Notes, + ).StructScan(&inv) + if err != nil { + return nil, fmt.Errorf("update invoice: %w", err) + } + + s.audit.Log(ctx, "update", "invoice", &inv.ID, old, inv) + return &inv, nil +} + +func (s *InvoiceService) UpdateStatus(ctx context.Context, tenantID, invoiceID uuid.UUID, newStatus string) (*models.Invoice, error) { + old, err := s.GetByID(ctx, tenantID, invoiceID) + if err != nil { + return nil, err + } + if old == nil { + return nil, fmt.Errorf("invoice not found") + } + + // Validate transitions + validTransitions := map[string][]string{ + "draft": {"sent", "cancelled"}, + "sent": {"paid", "cancelled"}, + "paid": {}, + "cancelled": {}, + } + allowed := validTransitions[old.Status] + valid := false + for _, s := range allowed { + if s == newStatus { + valid = true + break + } + } + if !valid { + return nil, fmt.Errorf("invalid status transition from %s to %s", old.Status, newStatus) + } + + var paidAt *time.Time + if newStatus == "paid" { + now := time.Now() + paidAt = &now + } + + var inv models.Invoice + err = s.db.QueryRowxContext(ctx, + `UPDATE invoices SET status = $3, paid_at = COALESCE($4, paid_at), updated_at = now() + WHERE tenant_id = $1 AND id = $2 + RETURNING `+invoiceCols, + tenantID, invoiceID, newStatus, paidAt, + ).StructScan(&inv) + if err != nil { + return nil, fmt.Errorf("update invoice status: %w", err) + } + + s.audit.Log(ctx, "update", "invoice", &inv.ID, old, inv) + return &inv, nil +} diff --git a/backend/internal/services/time_entry_service.go b/backend/internal/services/time_entry_service.go new file mode 100644 index 0000000..f455145 --- /dev/null +++ b/backend/internal/services/time_entry_service.go @@ -0,0 +1,276 @@ +package services + +import ( + "context" + "database/sql" + "fmt" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "mgit.msbls.de/m/KanzlAI-mGMT/internal/models" +) + +type TimeEntryService struct { + db *sqlx.DB + audit *AuditService +} + +func NewTimeEntryService(db *sqlx.DB, audit *AuditService) *TimeEntryService { + return &TimeEntryService{db: db, audit: audit} +} + +type CreateTimeEntryInput struct { + CaseID uuid.UUID `json:"case_id"` + Date string `json:"date"` + DurationMinutes int `json:"duration_minutes"` + Description string `json:"description"` + Activity *string `json:"activity,omitempty"` + Billable *bool `json:"billable,omitempty"` + HourlyRate *float64 `json:"hourly_rate,omitempty"` +} + +type UpdateTimeEntryInput struct { + Date *string `json:"date,omitempty"` + DurationMinutes *int `json:"duration_minutes,omitempty"` + Description *string `json:"description,omitempty"` + Activity *string `json:"activity,omitempty"` + Billable *bool `json:"billable,omitempty"` + HourlyRate *float64 `json:"hourly_rate,omitempty"` +} + +type TimeEntryFilter struct { + CaseID *uuid.UUID + UserID *uuid.UUID + From string + To string + Limit int + Offset int +} + +type TimeEntrySummary struct { + GroupKey string `db:"group_key" json:"group_key"` + TotalMinutes int `db:"total_minutes" json:"total_minutes"` + BillableMinutes int `db:"billable_minutes" json:"billable_minutes"` + TotalAmount float64 `db:"total_amount" json:"total_amount"` + EntryCount int `db:"entry_count" json:"entry_count"` +} + +const timeEntryCols = `id, tenant_id, case_id, user_id, date, duration_minutes, description, + activity, billable, billed, invoice_id, hourly_rate, created_at, updated_at` + +func (s *TimeEntryService) ListForCase(ctx context.Context, tenantID, caseID uuid.UUID) ([]models.TimeEntry, error) { + var entries []models.TimeEntry + err := s.db.SelectContext(ctx, &entries, + `SELECT `+timeEntryCols+` FROM time_entries + WHERE tenant_id = $1 AND case_id = $2 + ORDER BY date DESC, created_at DESC`, + tenantID, caseID) + if err != nil { + return nil, fmt.Errorf("list time entries for case: %w", err) + } + return entries, nil +} + +func (s *TimeEntryService) List(ctx context.Context, tenantID uuid.UUID, filter TimeEntryFilter) ([]models.TimeEntry, int, error) { + if filter.Limit <= 0 { + filter.Limit = 20 + } + if filter.Limit > 100 { + filter.Limit = 100 + } + + where := "WHERE tenant_id = $1" + args := []any{tenantID} + argIdx := 2 + + if filter.CaseID != nil { + where += fmt.Sprintf(" AND case_id = $%d", argIdx) + args = append(args, *filter.CaseID) + argIdx++ + } + if filter.UserID != nil { + where += fmt.Sprintf(" AND user_id = $%d", argIdx) + args = append(args, *filter.UserID) + argIdx++ + } + if filter.From != "" { + where += fmt.Sprintf(" AND date >= $%d", argIdx) + args = append(args, filter.From) + argIdx++ + } + if filter.To != "" { + where += fmt.Sprintf(" AND date <= $%d", argIdx) + args = append(args, filter.To) + argIdx++ + } + + var total int + err := s.db.GetContext(ctx, &total, + "SELECT COUNT(*) FROM time_entries "+where, args...) + if err != nil { + return nil, 0, fmt.Errorf("count time entries: %w", err) + } + + query := fmt.Sprintf("SELECT %s FROM time_entries %s ORDER BY date DESC, created_at DESC LIMIT $%d OFFSET $%d", + timeEntryCols, where, argIdx, argIdx+1) + args = append(args, filter.Limit, filter.Offset) + + var entries []models.TimeEntry + err = s.db.SelectContext(ctx, &entries, query, args...) + if err != nil { + return nil, 0, fmt.Errorf("list time entries: %w", err) + } + return entries, total, nil +} + +func (s *TimeEntryService) GetByID(ctx context.Context, tenantID, entryID uuid.UUID) (*models.TimeEntry, error) { + var entry models.TimeEntry + err := s.db.GetContext(ctx, &entry, + `SELECT `+timeEntryCols+` FROM time_entries WHERE tenant_id = $1 AND id = $2`, + tenantID, entryID) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("get time entry: %w", err) + } + return &entry, nil +} + +func (s *TimeEntryService) Create(ctx context.Context, tenantID, userID uuid.UUID, input CreateTimeEntryInput) (*models.TimeEntry, error) { + billable := true + if input.Billable != nil { + billable = *input.Billable + } + + // If no hourly rate provided, look up the current billing rate + hourlyRate := input.HourlyRate + if hourlyRate == nil { + var rate float64 + err := s.db.GetContext(ctx, &rate, + `SELECT rate FROM billing_rates + WHERE tenant_id = $1 AND (user_id = $2 OR user_id IS NULL) + AND valid_from <= $3 AND (valid_to IS NULL OR valid_to >= $3) + ORDER BY user_id NULLS LAST LIMIT 1`, + tenantID, userID, input.Date) + if err == nil { + hourlyRate = &rate + } + } + + var entry models.TimeEntry + err := s.db.QueryRowxContext(ctx, + `INSERT INTO time_entries (tenant_id, case_id, user_id, date, duration_minutes, description, activity, billable, hourly_rate) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING `+timeEntryCols, + tenantID, input.CaseID, userID, input.Date, input.DurationMinutes, input.Description, input.Activity, billable, hourlyRate, + ).StructScan(&entry) + if err != nil { + return nil, fmt.Errorf("create time entry: %w", err) + } + + s.audit.Log(ctx, "create", "time_entry", &entry.ID, nil, entry) + return &entry, nil +} + +func (s *TimeEntryService) Update(ctx context.Context, tenantID, entryID uuid.UUID, input UpdateTimeEntryInput) (*models.TimeEntry, error) { + old, err := s.GetByID(ctx, tenantID, entryID) + if err != nil { + return nil, err + } + if old == nil { + return nil, fmt.Errorf("time entry not found") + } + if old.Billed { + return nil, fmt.Errorf("cannot update a billed time entry") + } + + var entry models.TimeEntry + err = s.db.QueryRowxContext(ctx, + `UPDATE time_entries SET + date = COALESCE($3, date), + duration_minutes = COALESCE($4, duration_minutes), + description = COALESCE($5, description), + activity = COALESCE($6, activity), + billable = COALESCE($7, billable), + hourly_rate = COALESCE($8, hourly_rate), + updated_at = now() + WHERE tenant_id = $1 AND id = $2 + RETURNING `+timeEntryCols, + tenantID, entryID, input.Date, input.DurationMinutes, input.Description, input.Activity, input.Billable, input.HourlyRate, + ).StructScan(&entry) + if err != nil { + return nil, fmt.Errorf("update time entry: %w", err) + } + + s.audit.Log(ctx, "update", "time_entry", &entry.ID, old, entry) + return &entry, nil +} + +func (s *TimeEntryService) Delete(ctx context.Context, tenantID, entryID uuid.UUID) error { + old, err := s.GetByID(ctx, tenantID, entryID) + if err != nil { + return err + } + if old == nil { + return fmt.Errorf("time entry not found") + } + if old.Billed { + return fmt.Errorf("cannot delete a billed time entry") + } + + _, err = s.db.ExecContext(ctx, + `DELETE FROM time_entries WHERE tenant_id = $1 AND id = $2`, + tenantID, entryID) + if err != nil { + return fmt.Errorf("delete time entry: %w", err) + } + + s.audit.Log(ctx, "delete", "time_entry", &entryID, old, nil) + return nil +} + +func (s *TimeEntryService) Summary(ctx context.Context, tenantID uuid.UUID, groupBy string, from, to string) ([]TimeEntrySummary, error) { + var groupExpr string + switch groupBy { + case "user": + groupExpr = "user_id::text" + case "month": + groupExpr = "to_char(date, 'YYYY-MM')" + default: + groupExpr = "case_id::text" + } + + where := "WHERE tenant_id = $1" + args := []any{tenantID} + argIdx := 2 + + if from != "" { + where += fmt.Sprintf(" AND date >= $%d", argIdx) + args = append(args, from) + argIdx++ + } + if to != "" { + where += fmt.Sprintf(" AND date <= $%d", argIdx) + args = append(args, to) + argIdx++ + } + + query := fmt.Sprintf(`SELECT %s AS group_key, + SUM(duration_minutes) AS total_minutes, + SUM(CASE WHEN billable THEN duration_minutes ELSE 0 END) AS billable_minutes, + SUM(CASE WHEN billable AND hourly_rate IS NOT NULL THEN duration_minutes * hourly_rate / 60.0 ELSE 0 END) AS total_amount, + COUNT(*) AS entry_count + FROM time_entries %s + GROUP BY %s + ORDER BY %s`, + groupExpr, where, groupExpr, groupExpr) + + var summaries []TimeEntrySummary + err := s.db.SelectContext(ctx, &summaries, query, args...) + if err != nil { + return nil, fmt.Errorf("time entry summary: %w", err) + } + return summaries, nil +} diff --git a/frontend/src/app/(app)/abrechnung/page.tsx b/frontend/src/app/(app)/abrechnung/page.tsx new file mode 100644 index 0000000..ec9babb --- /dev/null +++ b/frontend/src/app/(app)/abrechnung/page.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import type { TimeEntry } from "@/lib/types"; +import { format } from "date-fns"; +import { de } from "date-fns/locale"; +import { Timer, Loader2 } from "lucide-react"; +import { useState } from "react"; +import Link from "next/link"; +import { Breadcrumb } from "@/components/layout/Breadcrumb"; + +function formatDuration(minutes: number): string { + const h = Math.floor(minutes / 60); + const m = minutes % 60; + if (h === 0) return `${m}min`; + if (m === 0) return `${h}h`; + return `${h}h ${m}min`; +} + +export default function AbrechnungPage() { + const [from, setFrom] = useState(() => { + const d = new Date(); + d.setDate(1); + return format(d, "yyyy-MM-dd"); + }); + const [to, setTo] = useState(() => format(new Date(), "yyyy-MM-dd")); + + const { data, isLoading } = useQuery({ + queryKey: ["time-entries", from, to], + queryFn: () => + api.get<{ time_entries: TimeEntry[]; total: number }>( + `/time-entries?from=${from}&to=${to}&limit=100`, + ), + }); + + const entries = data?.time_entries ?? []; + const totalMinutes = entries.reduce((s, e) => s + e.duration_minutes, 0); + const billableMinutes = entries + .filter((e) => e.billable) + .reduce((s, e) => s + e.duration_minutes, 0); + const totalAmount = entries + .filter((e) => e.billable && e.hourly_rate) + .reduce((s, e) => s + (e.duration_minutes / 60) * (e.hourly_rate ?? 0), 0); + + return ( +
Gesamt
++ {formatDuration(totalMinutes)} +
+Abrechenbar
++ {formatDuration(billableMinutes)} +
+Betrag
++ {totalAmount.toFixed(2)} EUR +
++ Keine Zeiteintraege im gewaehlten Zeitraum. +
++ {entry.description} +
++ Rechnung nicht gefunden. +
++ {invoice.client_name} +
+Empfaenger
++ {invoice.client_name} +
+ {invoice.client_address && ( ++ {invoice.client_address} +
+ )} +Rechnungsdatum
++ {format(new Date(invoice.issued_at), "d. MMMM yyyy", { locale: de })} +
+Faellig am
++ {format(new Date(invoice.due_at), "d. MMMM yyyy", { locale: de })} +
+Bezahlt am
++ {format(new Date(invoice.paid_at), "d. MMMM yyyy", { locale: de })} +
+| Beschreibung | +Betrag | +
|---|---|
| + {item.description} + {item.duration_minutes && item.hourly_rate && ( + + ({Math.floor(item.duration_minutes / 60)}h{" "} + {item.duration_minutes % 60}min x {item.hourly_rate} EUR/h) + + )} + | ++ {item.amount.toFixed(2)} EUR + | +
Anmerkungen
+{invoice.notes}
++ Keine Rechnungen vorhanden. +
++ {inv.invoice_number} +
+ + {STATUS_LABEL[inv.status]} + ++ {inv.client_name} + {inv.issued_at && + ` — ${format(new Date(inv.issued_at), "d. MMM yyyy", { locale: de })}`} +
++ {inv.total.toFixed(2)} EUR +
+ + ))} ++ Keine Zeiteintraege vorhanden. +
++ {entry.description} +
+ {entry.activity && ( + + {ACTIVITIES.find((a) => a.value === entry.activity)?.label ?? entry.activity} + + )} + {!entry.billable && ( + + nicht abrechenbar + + )} + {entry.billed && ( + + abgerechnet + + )} ++ Noch keine Stundensaetze definiert. +
++ {r.rate.toFixed(2)} {r.currency}/h +
++ {r.user_id ? `Benutzer: ${r.user_id.slice(0, 8)}...` : "Standard (alle Benutzer)"} +
++ Ab{" "} + {format(new Date(r.valid_from), "d. MMM yyyy", { locale: de })} +
+ {r.valid_to && ( ++ Bis{" "} + {format(new Date(r.valid_to), "d. MMM yyyy", { locale: de })} +
+ )} +