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 }