feat: time tracking + billing — hourly rates, time entries, invoices (P1)

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)
This commit is contained in:
m
2026-03-30 11:24:36 +02:00
parent 8e65463130
commit 238811727d
23 changed files with 2346 additions and 129 deletions

View File

@@ -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
}

View File

@@ -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
}

View File

@@ -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
}