feat: Payment Terminal Compliance Modul — Phase 1+2

1. Control-Bibliothek: 130 Controls in 10 Domaenen (payment_controls_v1.json)
   - PAY (20): Transaction Flow, Idempotenz, State Machine
   - LOG (15): Audit Trail, PAN-Maskierung, Event-Typen
   - CRYPTO (15): Secrets, HSM, P2PE, TLS
   - API (15): Auth, RBAC, Rate Limiting, Injection
   - TERM (15): ZVT/OPI, Heartbeat, Offline-Queue
   - FW (10): Firmware Signing, Secure Boot, Tamper Detection
   - REP (10): Reconciliation, Tagesabschluss, GoBD
   - ACC (10): MFA, Session, Least Privilege
   - ERR (10): Recovery, Circuit Breaker, Offline-Modus
   - BLD (10): CI/CD, SBOM, Container Scanning
2. Backend: DB Migration 024, Go Handler (5 Endpoints), Routes
3. Frontend: /sdk/payment-compliance mit Control-Browser + Assessment-Wizard

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benjamin Admin
2026-04-13 07:51:59 +02:00
parent dd64e33e88
commit 38d3d24121
6 changed files with 878 additions and 0 deletions

View File

@@ -0,0 +1,285 @@
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{}
}
// 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})
}