Phase 3 des Payment Compliance Moduls: 1. Backend: Tender Upload + LLM Requirement Extraction + Control Matching - DB Migration 025 (tender_analyses Tabelle) - TenderHandlers: Upload, Extract, Match, List, Get (5 Endpoints) - LLM-Extraktion via Anthropic API mit Keyword-Fallback - Control-Matching mit Domain-Bonus + Keyword-Overlap Relevance 2. Frontend: Dritter Tab "Ausschreibung" in /sdk/payment-compliance - PDF/TXT/Word Upload mit Drag-Area - Automatische Analyse-Pipeline (Upload → Extract → Match) - Ergebnis-Dashboard: Abgedeckt/Teilweise/Luecken - Requirement-by-Requirement Matching mit Control-IDs + Relevanz% - Gap-Beschreibung fuer nicht-gematchte Requirements - Analyse-Historie mit Klick-to-Detail Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
291 lines
9.4 KiB
Go
291 lines
9.4 KiB
Go
package handlers
|
|
|
|
import (
|
|
"encoding/json"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"time"
|
|
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
"github.com/jackc/pgx/v5/pgxpool"
|
|
)
|
|
|
|
// PaymentHandlers handles payment compliance endpoints
|
|
type PaymentHandlers struct {
|
|
pool *pgxpool.Pool
|
|
controls *PaymentControlLibrary
|
|
}
|
|
|
|
// PaymentControlLibrary holds the control catalog
|
|
type PaymentControlLibrary struct {
|
|
Domains []PaymentDomain `json:"domains"`
|
|
Controls []PaymentControl `json:"controls"`
|
|
}
|
|
|
|
type PaymentDomain struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
Description string `json:"description"`
|
|
}
|
|
|
|
type PaymentControl struct {
|
|
ControlID string `json:"control_id"`
|
|
Domain string `json:"domain"`
|
|
Title string `json:"title"`
|
|
Objective string `json:"objective"`
|
|
CheckTarget string `json:"check_target"`
|
|
Evidence []string `json:"evidence"`
|
|
Automation string `json:"automation"`
|
|
}
|
|
|
|
type PaymentAssessment struct {
|
|
ID uuid.UUID `json:"id"`
|
|
TenantID uuid.UUID `json:"tenant_id"`
|
|
ProjectName string `json:"project_name"`
|
|
TenderReference string `json:"tender_reference,omitempty"`
|
|
CustomerName string `json:"customer_name,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
SystemType string `json:"system_type,omitempty"`
|
|
PaymentMethods json.RawMessage `json:"payment_methods,omitempty"`
|
|
Protocols json.RawMessage `json:"protocols,omitempty"`
|
|
TotalControls int `json:"total_controls"`
|
|
ControlsPassed int `json:"controls_passed"`
|
|
ControlsFailed int `json:"controls_failed"`
|
|
ControlsPartial int `json:"controls_partial"`
|
|
ControlsNA int `json:"controls_not_applicable"`
|
|
ControlsUnchecked int `json:"controls_not_checked"`
|
|
ComplianceScore float64 `json:"compliance_score"`
|
|
Status string `json:"status"`
|
|
ControlResults json.RawMessage `json:"control_results,omitempty"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
UpdatedAt time.Time `json:"updated_at"`
|
|
CreatedBy string `json:"created_by,omitempty"`
|
|
}
|
|
|
|
// NewPaymentHandlers creates payment handlers with loaded control library
|
|
func NewPaymentHandlers(pool *pgxpool.Pool) *PaymentHandlers {
|
|
lib := loadControlLibrary()
|
|
return &PaymentHandlers{pool: pool, controls: lib}
|
|
}
|
|
|
|
func loadControlLibrary() *PaymentControlLibrary {
|
|
// Try to load from policies directory
|
|
paths := []string{
|
|
"policies/payment_controls_v1.json",
|
|
"/app/policies/payment_controls_v1.json",
|
|
}
|
|
for _, p := range paths {
|
|
data, err := os.ReadFile(p)
|
|
if err != nil {
|
|
// Try relative to executable
|
|
execDir, _ := os.Executable()
|
|
altPath := filepath.Join(filepath.Dir(execDir), p)
|
|
data, err = os.ReadFile(altPath)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
}
|
|
var lib PaymentControlLibrary
|
|
if err := json.Unmarshal(data, &lib); err == nil {
|
|
return &lib
|
|
}
|
|
}
|
|
return &PaymentControlLibrary{}
|
|
}
|
|
|
|
// GetControlLibrary returns the loaded control library (for tender matching)
|
|
func (h *PaymentHandlers) GetControlLibrary() *PaymentControlLibrary {
|
|
return h.controls
|
|
}
|
|
|
|
// ListControls returns the control library
|
|
func (h *PaymentHandlers) ListControls(c *gin.Context) {
|
|
domain := c.Query("domain")
|
|
automation := c.Query("automation")
|
|
|
|
controls := h.controls.Controls
|
|
if domain != "" {
|
|
var filtered []PaymentControl
|
|
for _, ctrl := range controls {
|
|
if ctrl.Domain == domain {
|
|
filtered = append(filtered, ctrl)
|
|
}
|
|
}
|
|
controls = filtered
|
|
}
|
|
if automation != "" {
|
|
var filtered []PaymentControl
|
|
for _, ctrl := range controls {
|
|
if ctrl.Automation == automation {
|
|
filtered = append(filtered, ctrl)
|
|
}
|
|
}
|
|
controls = filtered
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"controls": controls,
|
|
"domains": h.controls.Domains,
|
|
"total": len(controls),
|
|
})
|
|
}
|
|
|
|
// CreateAssessment creates a new payment compliance assessment
|
|
func (h *PaymentHandlers) CreateAssessment(c *gin.Context) {
|
|
var req PaymentAssessment
|
|
if err := c.ShouldBindJSON(&req); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
|
if tenantID == uuid.Nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
|
return
|
|
}
|
|
|
|
req.ID = uuid.New()
|
|
req.TenantID = tenantID
|
|
req.Status = "draft"
|
|
req.TotalControls = len(h.controls.Controls)
|
|
req.ControlsUnchecked = req.TotalControls
|
|
req.CreatedAt = time.Now()
|
|
req.UpdatedAt = time.Now()
|
|
|
|
_, err := h.pool.Exec(c.Request.Context(), `
|
|
INSERT INTO payment_compliance_assessments (
|
|
id, tenant_id, project_name, tender_reference, customer_name, description,
|
|
system_type, payment_methods, protocols,
|
|
total_controls, controls_not_checked, status, created_by
|
|
) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)`,
|
|
req.ID, req.TenantID, req.ProjectName, req.TenderReference, req.CustomerName, req.Description,
|
|
req.SystemType, req.PaymentMethods, req.Protocols,
|
|
req.TotalControls, req.ControlsUnchecked, req.Status, req.CreatedBy,
|
|
)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusCreated, req)
|
|
}
|
|
|
|
// ListAssessments lists all payment assessments for a tenant
|
|
func (h *PaymentHandlers) ListAssessments(c *gin.Context) {
|
|
tenantID, _ := uuid.Parse(c.GetHeader("X-Tenant-ID"))
|
|
if tenantID == uuid.Nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "tenant ID required"})
|
|
return
|
|
}
|
|
|
|
rows, err := h.pool.Query(c.Request.Context(), `
|
|
SELECT id, tenant_id, project_name, tender_reference, customer_name,
|
|
system_type, total_controls, controls_passed, controls_failed,
|
|
controls_partial, controls_not_applicable, controls_not_checked,
|
|
compliance_score, status, created_at, updated_at
|
|
FROM payment_compliance_assessments
|
|
WHERE tenant_id = $1
|
|
ORDER BY created_at DESC`, tenantID)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
defer rows.Close()
|
|
|
|
var assessments []PaymentAssessment
|
|
for rows.Next() {
|
|
var a PaymentAssessment
|
|
rows.Scan(&a.ID, &a.TenantID, &a.ProjectName, &a.TenderReference, &a.CustomerName,
|
|
&a.SystemType, &a.TotalControls, &a.ControlsPassed, &a.ControlsFailed,
|
|
&a.ControlsPartial, &a.ControlsNA, &a.ControlsUnchecked,
|
|
&a.ComplianceScore, &a.Status, &a.CreatedAt, &a.UpdatedAt)
|
|
assessments = append(assessments, a)
|
|
}
|
|
if assessments == nil {
|
|
assessments = []PaymentAssessment{}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"assessments": assessments, "total": len(assessments)})
|
|
}
|
|
|
|
// GetAssessment returns a single assessment with control results
|
|
func (h *PaymentHandlers) GetAssessment(c *gin.Context) {
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
|
return
|
|
}
|
|
|
|
var a PaymentAssessment
|
|
err = h.pool.QueryRow(c.Request.Context(), `
|
|
SELECT id, tenant_id, project_name, tender_reference, customer_name, description,
|
|
system_type, payment_methods, protocols,
|
|
total_controls, controls_passed, controls_failed, controls_partial,
|
|
controls_not_applicable, controls_not_checked, compliance_score,
|
|
status, control_results, created_at, updated_at, created_by
|
|
FROM payment_compliance_assessments WHERE id = $1`, id).Scan(
|
|
&a.ID, &a.TenantID, &a.ProjectName, &a.TenderReference, &a.CustomerName, &a.Description,
|
|
&a.SystemType, &a.PaymentMethods, &a.Protocols,
|
|
&a.TotalControls, &a.ControlsPassed, &a.ControlsFailed, &a.ControlsPartial,
|
|
&a.ControlsNA, &a.ControlsUnchecked, &a.ComplianceScore,
|
|
&a.Status, &a.ControlResults, &a.CreatedAt, &a.UpdatedAt, &a.CreatedBy)
|
|
if err != nil {
|
|
c.JSON(http.StatusNotFound, gin.H{"error": "assessment not found"})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, a)
|
|
}
|
|
|
|
// UpdateControlVerdict updates the verdict for a single control
|
|
func (h *PaymentHandlers) UpdateControlVerdict(c *gin.Context) {
|
|
id, err := uuid.Parse(c.Param("id"))
|
|
if err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid ID"})
|
|
return
|
|
}
|
|
|
|
var body struct {
|
|
ControlID string `json:"control_id"`
|
|
Verdict string `json:"verdict"` // passed, failed, partial, na, unchecked
|
|
Evidence string `json:"evidence,omitempty"`
|
|
Notes string `json:"notes,omitempty"`
|
|
}
|
|
if err := c.ShouldBindJSON(&body); err != nil {
|
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
// Update the control_results JSONB and recalculate scores
|
|
_, err = h.pool.Exec(c.Request.Context(), `
|
|
WITH updated AS (
|
|
SELECT id,
|
|
COALESCE(control_results, '[]'::jsonb) AS existing_results
|
|
FROM payment_compliance_assessments WHERE id = $1
|
|
)
|
|
UPDATE payment_compliance_assessments SET
|
|
control_results = (
|
|
SELECT jsonb_agg(
|
|
CASE WHEN elem->>'control_id' = $2 THEN
|
|
jsonb_build_object('control_id', $2, 'verdict', $3, 'evidence', $4, 'notes', $5)
|
|
ELSE elem END
|
|
) FROM updated, jsonb_array_elements(
|
|
CASE WHEN existing_results @> jsonb_build_array(jsonb_build_object('control_id', $2))
|
|
THEN existing_results
|
|
ELSE existing_results || jsonb_build_array(jsonb_build_object('control_id', $2, 'verdict', $3, 'evidence', $4, 'notes', $5))
|
|
END
|
|
) AS elem
|
|
),
|
|
updated_at = NOW()
|
|
WHERE id = $1`,
|
|
id, body.ControlID, body.Verdict, body.Evidence, body.Notes)
|
|
if err != nil {
|
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
|
return
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{"status": "updated", "control_id": body.ControlID, "verdict": body.Verdict})
|
|
}
|