// Package einvoiceapi liefert das E-Rechnung-Ergebnis je Revision // (MET-06/MET-07/QA-13) ueber HTTP und erlaubt die Neuprüfung. Keine eigene // Lese- oder Validierungslogik, nur Wrapper um internal/einvoice. package einvoiceapi import ( "context" "encoding/json" "errors" "net/http" "strings" "github.com/jackc/pgx/v5" "github.com/jackc/pgx/v5/pgxpool" "gitea.perlbach24.de/scripte/nexarch/dms/internal/einvoice" "gitea.perlbach24.de/scripte/nexarch/dms/internal/moduleauth" ) // PermissionChecker entscheidet, ob der Aufrufer Zugriff auf EIN bestimmtes // Dokument hat (Muster wie versioningapi/downloadapi). type PermissionChecker interface { CanAccessDocument(ctx context.Context, callerUserID, documentID string) (bool, error) } type Server struct { pool *pgxpool.Pool enqueuer *einvoice.Enqueuer permissions PermissionChecker } func NewServer(pool *pgxpool.Pool, enqueuer *einvoice.Enqueuer, permissions PermissionChecker) *Server { return &Server{pool: pool, enqueuer: enqueuer, permissions: permissions} } func (s *Server) RegisterRoutes(mux *http.ServeMux) { mux.HandleFunc("GET /documents/{id}/revisions/{revisionID}/einvoice", s.handleGet) mux.HandleFunc("POST /documents/{id}/revisions/{revisionID}/einvoice/recheck", s.handleRecheck) } func writeJSON(w http.ResponseWriter, status int, v any) { w.Header().Set("Content-Type", "application/json") w.WriteHeader(status) _ = json.NewEncoder(w).Encode(v) } func writeError(w http.ResponseWriter, status int, msg string) { writeJSON(w, status, map[string]string{"error": msg}) } func actor(r *http.Request) string { if id, ok := moduleauth.IdentityFrom(r.Context()); ok && id.UserID != "" { return id.UserID } return strings.TrimSpace(r.URL.Query().Get("caller")) } // authorize prueft Anmeldung UND Dokumentzugriff; liefert bei Ablehnung // bereits die passende Antwort und false. func (s *Server) authorize(w http.ResponseWriter, r *http.Request, documentID string) bool { a := actor(r) if a == "" { writeError(w, http.StatusUnauthorized, "nicht angemeldet") return false } ok, err := s.permissions.CanAccessDocument(r.Context(), a, documentID) if err != nil { writeError(w, http.StatusInternalServerError, "berechtigung pruefen: "+err.Error()) return false } if !ok { writeError(w, http.StatusForbidden, "kein zugriff auf dieses dokument") return false } return true } // readResultDTO ist die Stufe "intern stimmig": das im PDF/XML gefundene // Format und ob das Auslesen selbst gelungen ist (revision_einvoice). type readResultDTO struct { Format string `json:"format"` Status string `json:"status"` Findings []string `json:"findings"` } // conformanceDTO ist die Stufe "technisch gueltig"/"konform" // (revision_einvoice_conformance, MET-07): getrennt vom Auslesen, damit // nie ein "gueltig" ohne Pruefung entsteht. type conformanceDTO struct { Status string `json:"status"` RulesetVersion string `json:"ruleset_version"` Scenario string `json:"scenario"` ValidatorVersion string `json:"validator_version"` Reason string `json:"reason"` Messages []einvoice.Message `json:"messages"` } type resultDTO struct { Read *readResultDTO `json:"read"` Conformance *conformanceDTO `json:"conformance"` } func loadRead(ctx context.Context, pool *pgxpool.Pool, revisionID string) (*readResultDTO, error) { var format, status string var raw []byte err := pool.QueryRow(ctx, `SELECT format, status, findings FROM revision_einvoice WHERE revision_id = $1`, revisionID).Scan(&format, &status, &raw) if errors.Is(err, pgx.ErrNoRows) { return nil, nil } if err != nil { return nil, err } var findings []struct { Code string `json:"code"` Message string `json:"message"` } if len(raw) > 0 { if err := json.Unmarshal(raw, &findings); err != nil { return nil, err } } out := make([]string, len(findings)) for i, f := range findings { out[i] = f.Code + ": " + f.Message } return &readResultDTO{Format: format, Status: status, Findings: out}, nil } func (s *Server) handleGet(w http.ResponseWriter, r *http.Request) { documentID, revisionID := r.PathValue("id"), r.PathValue("revisionID") if !s.authorize(w, r, documentID) { return } read, err := loadRead(r.Context(), s.pool, revisionID) if err != nil { writeError(w, http.StatusInternalServerError, "e-rechnung-ergebnis lesen: "+err.Error()) return } conf, err := einvoice.LatestConformance(r.Context(), s.pool, revisionID) if err != nil { writeError(w, http.StatusInternalServerError, "konformitaet lesen: "+err.Error()) return } if read == nil && conf == nil { writeError(w, http.StatusNotFound, "kein e-rechnung-ergebnis fuer diese revision") return } out := resultDTO{Read: read} if conf != nil { out.Conformance = &conformanceDTO{ Status: conf.Status, RulesetVersion: conf.RulesetVersion, Scenario: conf.Scenario, ValidatorVersion: conf.ValidatorVersion, Reason: conf.Reason, Messages: conf.Messages, } } writeJSON(w, http.StatusOK, out) } func (s *Server) handleRecheck(w http.ResponseWriter, r *http.Request) { documentID, revisionID := r.PathValue("id"), r.PathValue("revisionID") if !s.authorize(w, r, documentID) { return } if s.enqueuer == nil { writeError(w, http.StatusServiceUnavailable, "neupruefung ist auf dieser instanz nicht konfiguriert") return } if err := s.enqueuer.Enqueue(r.Context(), documentID, revisionID); err != nil { writeError(w, http.StatusInternalServerError, "neupruefung einreihen: "+err.Error()) return } w.WriteHeader(http.StatusAccepted) }