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:
88
backend/internal/services/billing_rate_service.go
Normal file
88
backend/internal/services/billing_rate_service.go
Normal 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
|
||||
}
|
||||
292
backend/internal/services/invoice_service.go
Normal file
292
backend/internal/services/invoice_service.go
Normal 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
|
||||
}
|
||||
276
backend/internal/services/time_entry_service.go
Normal file
276
backend/internal/services/time_entry_service.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user