Files
patrickandClaude Sonnet 5.5 753af35888
Supply-Chain-Scan (Go) / govulncheck (pull_request) Failing after 42s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (push) Failing after 2s
CI / DMS-Modul (Lint/Build/Test/Coverage/Artefakt) (pull_request) Failing after 2s
DB-Integrationstests / db-tests (pull_request) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (push) Canceled after 0s
CI / Web-Barrierefreiheitspruefung (axe, Tastatur, Reflow, Textabstaende) (pull_request) Canceled after 0s
CI / Web (pdf-viewer) (push) Failing after 36s
CI / Web (shl) (push) Failing after 7s
Supply-Chain-Scan (Go) / govulncheck (push) Failing after 43s
CI / Core-Modul (Build/Vet/Test) (pull_request) Successful in 46s
CI / Web (dms-browser) (pull_request) Failing after 58s
CI / Web (pdf-viewer) (pull_request) Failing after 43s
Core-Schnittstellen-Vertragstests / contract-tests (pull_request) Successful in 18m38s
CI / Core-Modul (Build/Vet/Test) (push) Successful in 39s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (push) Successful in 3s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (push) Successful in 3s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (push) Failing after 15s
CI / Web (dms-browser) (push) Failing after 51s
CI / DMS Security-Test-Gate (AC5, Merge-Gate) (pull_request) Successful in 3s
CI / Connect Security-Test-Gate (QA-01 AC4, Merge-Gate) (pull_request) Successful in 4s
CI / DMS-Modul Supply-Chain-Scan (govulncheck) (pull_request) Failing after 14s
CI / Web (shl) (pull_request) Failing after 9s
Supply-Chain-Scan (npm) / npm-audit (pull_request) Failing after 1m16s
Core-Pflichttest-Gate / pflichttest-gate (pull_request) Successful in 18m46s
RBAC-Rollenhierarchie-Pflichtgate / rbac-policy-invariant (pull_request) Successful in 18m59s
connect: DATEV-Export in connect-app registriert, mit Rechtepruefung
/datev/exports braucht connect.datev.write (POST) bzw. connect.datev.read
(GET) mit der Rolle aus X-User-Role. Registriert nur mit
NEXARCH_CONNECT_APP_ARCHIVE_URL; die DATEV-Uebertragung bleibt aus.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-30 00:23:17 +01:00

226 lines
7.1 KiB
Go

// Package datevapi is the HTTP binding for DAT-03: triggering a DATEV
// export by period and viewing the export/transfer protocol. Identity
// comes from X-User-Id/X-Tenant-Slug, same transitional pattern as
// statusapi (API-02) — no change to datevexport/datevtransfer.
package datevapi
import (
"encoding/json"
"net/http"
"time"
"gitea.perlbach24.de/scripte/nexarch/common/policyclient"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevexport"
"gitea.perlbach24.de/scripte/nexarch/connect/internal/datevtransfer"
)
type Server struct {
Exports *datevexport.Store
Transfers *datevtransfer.Store
policy *policyclient.Client
}
func (s *Server) WithPolicy(client *policyclient.Client) *Server {
s.policy = client
return s
}
func NewServer(exports *datevexport.Store, transfers *datevtransfer.Store) *Server {
return &Server{Exports: exports, Transfers: transfers}
}
func (s *Server) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /datev/exports", s.requireIdentity(s.requirePermission("connect.datev.write", s.handleTrigger)))
mux.HandleFunc("GET /datev/exports", s.requireIdentity(s.requirePermission("connect.datev.read", s.handleList)))
}
func (s *Server) requirePermission(permission string, next func(w http.ResponseWriter, r *http.Request, tenantSlug string)) func(w http.ResponseWriter, r *http.Request, tenantSlug string) {
return func(w http.ResponseWriter, r *http.Request, tenantSlug string) {
if s.policy != nil {
allowed, err := s.policy.Allowed(r.Context(), tenantSlug, roleOf(r), permission)
if err != nil {
writeError(w, http.StatusServiceUnavailable, "berechtigungsdienst nicht erreichbar")
return
}
if !allowed {
writeError(w, http.StatusForbidden, "keine berechtigung")
return
}
}
next(w, r, tenantSlug)
}
}
func roleOf(r *http.Request) string {
if role := r.Header.Get("X-User-Role"); role != "" {
return role
}
return "user"
}
func (s *Server) requireIdentity(next func(w http.ResponseWriter, r *http.Request, tenantSlug string)) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("X-User-Id") == "" {
writeError(w, http.StatusUnauthorized, "X-User-Id fehlt")
return
}
tenant := r.Header.Get("X-Tenant-Slug")
if tenant == "" {
writeError(w, http.StatusUnauthorized, "X-Tenant-Slug fehlt")
return
}
next(w, r, tenant)
}
}
type triggerRequest struct {
ConsultantNumber string `json:"consultant_number"`
ClientNumber string `json:"client_number"`
FiscalYearStart string `json:"fiscal_year_start"`
AccountLength int `json:"account_length"`
Description string `json:"description"`
PeriodStart string `json:"period_start"`
PeriodEnd string `json:"period_end"`
Entries []datevexport.Entry `json:"entries"`
}
type runResponse struct {
ID string `json:"id"`
PeriodStart string `json:"period_start"`
PeriodEnd string `json:"period_end"`
EntryCount int `json:"entry_count"`
RetentionClass string `json:"retention_class"`
CreatedAt string `json:"created_at"`
TransferStatus string `json:"transfer_status,omitempty"`
TransferError string `json:"transfer_error,omitempty"`
}
func (s *Server) handleTrigger(w http.ResponseWriter, r *http.Request, tenantSlug string) {
var req triggerRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "ungueltige anfrage")
return
}
periodStart, err := time.Parse("2006-01-02", req.PeriodStart)
if err != nil {
writeError(w, http.StatusBadRequest, "period_start ungueltig, erwartet YYYY-MM-DD")
return
}
periodEnd, err := time.Parse("2006-01-02", req.PeriodEnd)
if err != nil {
writeError(w, http.StatusBadRequest, "period_end ungueltig, erwartet YYYY-MM-DD")
return
}
fiscalYearStart, err := time.Parse("2006-01-02", req.FiscalYearStart)
if err != nil {
writeError(w, http.StatusBadRequest, "fiscal_year_start ungueltig, erwartet YYYY-MM-DD")
return
}
if len(req.Entries) == 0 {
writeError(w, http.StatusBadRequest, "keine belege im export")
return
}
header := datevexport.Header{
ConsultantNumber: req.ConsultantNumber,
ClientNumber: req.ClientNumber,
FiscalYearStart: fiscalYearStart,
AccountLength: req.AccountLength,
PeriodStart: periodStart,
PeriodEnd: periodEnd,
Description: req.Description,
}
file, run, err := s.Exports.RunExport(r.Context(), tenantSlug, header, req.Entries, "")
if err != nil {
writeError(w, http.StatusUnprocessableEntity, err.Error())
return
}
resp := runResponse{
ID: run.ID,
PeriodStart: run.PeriodStart.Format("2006-01-02"),
PeriodEnd: run.PeriodEnd.Format("2006-01-02"),
EntryCount: run.EntryCount,
RetentionClass: run.RetentionClass,
CreatedAt: run.CreatedAt.Format(time.RFC3339),
}
if s.Transfers != nil {
transfer, terr := s.Transfers.Start(r.Context(), tenantSlug, run.ID, run.ID+".csv", file)
if terr != nil {
resp.TransferStatus = string(datevtransfer.StatusFailed)
resp.TransferError = terr.Error()
} else {
resp.TransferStatus = string(transfer.Status)
resp.TransferError = transfer.LastError
}
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusCreated)
_ = json.NewEncoder(w).Encode(resp)
}
type protocolEntry struct {
runResponse
}
func (s *Server) handleList(w http.ResponseWriter, r *http.Request, tenantSlug string) {
runs, err := s.Exports.List(r.Context(), tenantSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "exportlaeufe konnten nicht geladen werden")
return
}
var transfers []datevtransferListItem
if s.Transfers != nil {
ts, err := s.Transfers.ListForTenant(r.Context(), tenantSlug)
if err != nil {
writeError(w, http.StatusInternalServerError, "transfers konnten nicht geladen werden")
return
}
for _, t := range ts {
transfers = append(transfers, datevtransferListItem{exportRunID: t.ExportRunID, status: string(t.Status), lastError: t.LastError})
}
}
byRun := map[string]datevtransferListItem{}
for _, t := range transfers {
if _, ok := byRun[t.exportRunID]; !ok {
byRun[t.exportRunID] = t
}
}
out := make([]runResponse, 0, len(runs))
for _, run := range runs {
resp := runResponse{
ID: run.ID,
PeriodStart: run.PeriodStart.Format("2006-01-02"),
PeriodEnd: run.PeriodEnd.Format("2006-01-02"),
EntryCount: run.EntryCount,
RetentionClass: run.RetentionClass,
CreatedAt: run.CreatedAt.Format(time.RFC3339),
}
if t, ok := byRun[run.ID]; ok {
resp.TransferStatus = t.status
resp.TransferError = t.lastError
}
out = append(out, resp)
}
w.Header().Set("Content-Type", "application/json")
_ = json.NewEncoder(w).Encode(out)
}
type datevtransferListItem struct {
exportRunID string
status string
lastError string
}
func writeError(w http.ResponseWriter, status int, msg string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": msg})
}