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)
89 lines
2.6 KiB
Go
89 lines
2.6 KiB
Go
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
|
|
}
|