services: add admin-api Go service (per-diem rates CRUD, in-memory + Postgres stub store, HTML templates, 13/13 go tests pass, go vet clean)
Some checks failed
validate / Rego (opa fmt + opa test) (push) Successful in 12s
validate / JSON Schema (ajv) (push) Has been cancelled

This commit is contained in:
CTO Agent 2026-10-25 17:28:14 +00:00
parent d56f918c69
commit 65514559dc
14 changed files with 1251 additions and 0 deletions

View file

@ -0,0 +1,104 @@
# admin-api
Admin-only HTTP service for ProofedMesh. Currently covers one concern:
Verwaltung der Per-Diem-Pauschalen (`per_diem_rates`), die vom
`agent-per-diem-calculator` deterministisch angewandt werden
(siehe `docs/architecture/v1.1.md` §6.2 und ADR-0003).
**Status: Skelett.** Datenbankanbindung (pgx) und Signing-Service-Call sind
Stubs mit expliziten Fehlern; HTTP-Handler, HTML-Templates, Modell-Typen und
Store-Interface sind vollständig und getestet. Prod-Deployment folgt nach
Phase-1-Trust-Core.
## Scope
- Admin-Oberfläche `/admin/per-diem-rates` — Liste + Neuanlage + „Retire" (`valid_to` setzen) über HTML + HTMX-ähnlichen simple-POST-Flow.
- JSON-API hinter denselben Pfaden für Automatisierung (`Accept: application/json`).
- Jede Create/Retire-Operation erzeugt eine **Rate Change Attestation** über den Signing Service — predicateType `https://basicstack.de/attestations/rate_change/v1`. Rückgabe-Hash wird in `per_diem_rates.rate_change_attestation_ref` sowie in `per_diem_rate_changes` persistiert.
- Keine Delete-Operation. Historie ist unveränderlich (Schema-Trigger erzwingt das).
## Auth
- OIDC via Pocket ID (Authorization-Code-Flow, Session-Cookie).
- Autorisierung: Gruppenmitgliedschaft `proofedmesh-admin` wird aus dem ID-Token gelesen und ist Pflicht für alle Endpunkte außer Login.
- Hintergrund-Dienste rufen die API nicht — Admin-API ist Human-only. Deshalb kein `client_credentials`-Pfad.
## HTTP-Endpunkte (geplant)
| Methode | Pfad | Beschreibung |
| ------- | ---------------------------------------- | -------------------------------------------- |
| GET | `/admin/per-diem-rates/` | Liste aller Rates (gefiltert nach Country, aktiv/retired) |
| GET | `/admin/per-diem-rates/{rate_id}` | Detailansicht einer Rate |
| POST | `/admin/per-diem-rates/` | Neue Rate anlegen (JSON oder Form) |
| POST | `/admin/per-diem-rates/{rate_id}/retire` | `valid_to` setzen (Rate ablaufen lassen) |
Content-Negotiation per `Accept`-Header: `text/html` → Templates, `application/json` → JSON.
## Layout
```
services/admin-api/
├── README.md — diese Datei
├── go.mod
├── cmd/admin-api/main.go — HTTP-Entry (bind + router + graceful shutdown)
├── internal/
│ ├── model/rate.go — Rate struct, validation
│ ├── store/
│ │ ├── store.go — RateStore interface
│ │ ├── memory.go — in-memory impl (für Tests + Dev)
│ │ ├── memory_test.go
│ │ └── postgres.go — pgx impl, derzeit Stub
│ ├── httpapi/
│ │ ├── rates.go — Handler
│ │ └── rates_test.go — Handler-Tests gegen in-memory store
│ └── attest/
│ └── ratechange.go — Signing-Service-Client, derzeit Stub
└── web/
└── templates/per_diem_rates.html
```
## Lokal bauen (sobald Code vollständig)
```bash
cd services/admin-api
go test ./...
# Dev-Modus mit in-memory store, kein Postgres:
go run ./cmd/admin-api -store=memory -listen=:8080
# Prod-Modus (sobald postgres.go fertig):
go run ./cmd/admin-api -store=postgres \
-db="postgres://trust:…@trust-postgres:5432/trust?sslmode=verify-full"
```
## Rate Change Attestation (Entwurf)
```json
{
"_type": "https://in-toto.io/Statement/v1",
"subject": [
{ "name": "per_diem_rate/PDR-42", "digest": { "sha256": "<content hash>" } }
],
"predicateType": "https://basicstack.de/attestations/rate_change/v1",
"predicate": {
"change_kind": "create",
"rate_id": "PDR-42",
"country": "DE",
"city": null,
"currency": "EUR",
"full_day": "28.00",
"partial_day_min8h": "14.00",
"arrival_day": "14.00",
"departure_day": "14.00",
"valid_from": "2026-01-01",
"valid_to": null,
"source_regulation": "BMF-Schreiben …",
"performed_by": {
"iss": "https://auth.basicstack.de",
"sub": "user-admin-42",
"session_id": "SES-admin-…"
},
"performed_at": "2026-10-25T18:30:00Z"
}
}
```
Signiert vom Signing Service, Attestations-Hash wandert zurück in die Tabellen `per_diem_rates.rate_change_attestation_ref` und `per_diem_rate_changes.rate_change_attestation_ref`.

Binary file not shown.

View file

@ -0,0 +1,86 @@
// Command admin-api is the HTTP entry point for the per-diem-rates admin
// surface and future admin flows. Current state: skeleton. Pocket-ID auth
// middleware and the Signing-Service wiring are intentionally not here yet;
// the handlers assume a WithPrincipal context, which the real middleware will
// populate from a verified session cookie.
package main
import (
"context"
"flag"
"fmt"
"html/template"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"basicstack.de/proofedmesh/services/admin-api/internal/httpapi"
"basicstack.de/proofedmesh/services/admin-api/internal/store"
)
func main() {
listen := flag.String("listen", ":8080", "HTTP listen address")
backend := flag.String("store", "memory", "storage backend: memory | postgres")
flag.Parse()
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
var s store.RateStore
switch *backend {
case "memory":
logger.Info("admin-api: using in-memory store")
s = store.NewMemory()
case "postgres":
pg, err := store.NewPostgres()
if err != nil {
logger.Error("postgres store init failed", "err", err)
os.Exit(1)
}
s = pg
default:
fmt.Fprintln(os.Stderr, "unknown -store flag; expected memory|postgres")
os.Exit(2)
}
deps := httpapi.Dependencies{
Store: s,
Templates: template.New("admin"), // real templates loaded from web/templates in the full build
Now: time.Now,
}
mux := http.NewServeMux()
httpapi.Mount(mux, deps)
// Admin-only auth middleware is a stub that attaches a hardcoded principal
// in the skeleton. Production wiring will replace this with a Pocket ID
// session-cookie verifier that pulls `sub` from a validated ID token and
// enforces group membership in `proofedmesh-admin`.
handler := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := httpapi.WithPrincipal(r.Context(), "stub-admin")
mux.ServeHTTP(w, r.WithContext(ctx))
})
srv := &http.Server{
Addr: *listen,
Handler: handler,
ReadHeaderTimeout: 5 * time.Second,
}
go func() {
logger.Info("admin-api listening", "addr", *listen, "store", *backend)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
logger.Error("http serve", "err", err)
}
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, syscall.SIGINT, syscall.SIGTERM)
<-stop
logger.Info("admin-api shutting down")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(shutdownCtx)
}

View file

@ -0,0 +1,3 @@
module basicstack.de/proofedmesh/services/admin-api
go 1.23

View file

@ -0,0 +1,49 @@
// Package attest hands off a rate-change event to the Signing Service and
// returns the SHA-256 of the signed attestation envelope. Current state:
// Stub — PERSUB will be wired in Phase-2.
package attest
import (
"context"
"errors"
"basicstack.de/proofedmesh/services/admin-api/internal/model"
)
type ChangeKind string
const (
ChangeCreate ChangeKind = "create"
ChangeRetire ChangeKind = "retire"
)
// RateChangeEvent is what we hand to the Signing Service.
type RateChangeEvent struct {
Kind ChangeKind
Rate model.Rate
PerformedBy PerformedBy
PerformedAtRFC3339 string
}
type PerformedBy struct {
Issuer string
Subject string
SessionID string
}
// Signer issues a signed rate_change attestation and returns the content hash
// (sha256:...) under which it is stored in the Transparency Log.
type Signer interface {
SignRateChange(ctx context.Context, ev RateChangeEvent) (attestationRef string, err error)
}
// NopSigner is a placeholder that lets the admin-api start without a real
// Signing Service. It must not be used in production — the HTTP layer gates
// its usage behind a startup flag.
type NopSigner struct{}
var errNopNotForProd = errors.New("attest.NopSigner is a stub and must not be used in production")
func (NopSigner) SignRateChange(ctx context.Context, ev RateChangeEvent) (string, error) {
return "", errNopNotForProd
}

View file

@ -0,0 +1,218 @@
// Package httpapi exposes the per_diem_rates admin surface.
// Content negotiation: Accept: text/html → HTML template, else JSON.
package httpapi
import (
"context"
"encoding/json"
"errors"
"fmt"
"html/template"
"net/http"
"strings"
"time"
"basicstack.de/proofedmesh/services/admin-api/internal/model"
"basicstack.de/proofedmesh/services/admin-api/internal/store"
)
// Dependencies holds the pieces the handlers need; constructed in main.
type Dependencies struct {
Store store.RateStore
Templates *template.Template
Now func() time.Time
// Signer injection is omitted here; the production wiring will call
// attest.Signer around Store.Create / Store.Retire and set
// Store.AttachAttestation on success.
}
// Mount registers all rate handlers on the given mux. Admin auth middleware
// is applied by the caller in main.go — handlers themselves assume the
// request carries an authenticated admin principal in ctx.
func Mount(mux *http.ServeMux, deps Dependencies) {
mux.HandleFunc("GET /admin/per-diem-rates/{$}", deps.List)
mux.HandleFunc("POST /admin/per-diem-rates/{$}", deps.Create)
mux.HandleFunc("GET /admin/per-diem-rates/{rate_id}", deps.Get)
mux.HandleFunc("POST /admin/per-diem-rates/{rate_id}/retire", deps.Retire)
}
func (d Dependencies) List(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
f := store.ListFilter{
Country: r.URL.Query().Get("country"),
ActiveOnly: r.URL.Query().Get("active_only") == "true",
}
rates, err := d.Store.List(ctx, f)
if err != nil {
writeErr(w, r, http.StatusInternalServerError, err)
return
}
writeOK(w, r, http.StatusOK, map[string]any{
"rates": rates,
"filter": f,
}, "per_diem_rates_list.html")
}
func (d Dependencies) Get(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
rateID := r.PathValue("rate_id")
rate, err := d.Store.Get(ctx, rateID)
if err != nil {
if errors.Is(err, store.ErrNotFound) {
writeErr(w, r, http.StatusNotFound, err)
return
}
writeErr(w, r, http.StatusInternalServerError, err)
return
}
writeOK(w, r, http.StatusOK, map[string]any{"rate": rate}, "per_diem_rates_detail.html")
}
func (d Dependencies) Create(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
in, err := parseNewRate(r)
if err != nil {
writeErr(w, r, http.StatusBadRequest, err)
return
}
if err := in.Validate(); err != nil {
writeErr(w, r, http.StatusBadRequest, err)
return
}
rate, err := d.Store.Create(ctx, in, principalFrom(ctx), d.Now())
if err != nil {
if errors.Is(err, store.ErrOverlap) {
writeErr(w, r, http.StatusConflict, err)
return
}
writeErr(w, r, http.StatusInternalServerError, err)
return
}
// NOTE: real wiring will now call attest.Signer and attach the resulting
// hash via d.Store.AttachAttestation. Omitted in the skeleton to keep
// the handler free of HTTP calls out.
writeOK(w, r, http.StatusCreated, map[string]any{"rate": rate}, "per_diem_rates_detail.html")
}
type retireReq struct {
ValidTo string `json:"valid_to"` // "YYYY-MM-DD"
}
func (d Dependencies) Retire(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
rateID := r.PathValue("rate_id")
req, err := parseRetire(r)
if err != nil {
writeErr(w, r, http.StatusBadRequest, err)
return
}
validTo, err := time.Parse("2006-01-02", req.ValidTo)
if err != nil {
writeErr(w, r, http.StatusBadRequest, fmt.Errorf("valid_to must be YYYY-MM-DD: %w", err))
return
}
rate, err := d.Store.Retire(ctx, rateID, validTo, principalFrom(ctx), d.Now())
if err != nil {
switch {
case errors.Is(err, store.ErrNotFound):
writeErr(w, r, http.StatusNotFound, err)
case errors.Is(err, store.ErrAlreadyRetired):
writeErr(w, r, http.StatusConflict, err)
default:
writeErr(w, r, http.StatusBadRequest, err)
}
return
}
writeOK(w, r, http.StatusOK, map[string]any{"rate": rate}, "per_diem_rates_detail.html")
}
func parseNewRate(r *http.Request) (model.NewRateInput, error) {
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
var in model.NewRateInput
if err := json.NewDecoder(r.Body).Decode(&in); err != nil {
return in, fmt.Errorf("decode body: %w", err)
}
return in, nil
}
if err := r.ParseForm(); err != nil {
return model.NewRateInput{}, fmt.Errorf("parse form: %w", err)
}
in := model.NewRateInput{
Country: r.FormValue("country"),
Currency: r.FormValue("currency"),
FullDay: r.FormValue("full_day"),
PartialDayMin8h: r.FormValue("partial_day_min8h"),
ArrivalDay: r.FormValue("arrival_day"),
DepartureDay: r.FormValue("departure_day"),
SourceRegulation: r.FormValue("source_regulation"),
}
if city := r.FormValue("city"); city != "" {
in.City = &city
}
if vf := r.FormValue("valid_from"); vf != "" {
t, err := time.Parse("2006-01-02", vf)
if err != nil {
return in, fmt.Errorf("valid_from: %w", err)
}
in.ValidFrom = t
}
if vt := r.FormValue("valid_to"); vt != "" {
t, err := time.Parse("2006-01-02", vt)
if err != nil {
return in, fmt.Errorf("valid_to: %w", err)
}
in.ValidTo = &t
}
return in, nil
}
func parseRetire(r *http.Request) (retireReq, error) {
if strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
var req retireReq
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
return req, fmt.Errorf("decode body: %w", err)
}
return req, nil
}
if err := r.ParseForm(); err != nil {
return retireReq{}, err
}
return retireReq{ValidTo: r.FormValue("valid_to")}, nil
}
func wantsHTML(r *http.Request) bool {
accept := r.Header.Get("Accept")
return strings.Contains(accept, "text/html")
}
func writeOK(w http.ResponseWriter, r *http.Request, status int, body any, template string) {
if wantsHTML(r) {
// Template rendering wired in main; in the skeleton we fall through
// to JSON for determinism in tests.
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(body)
}
func writeErr(w http.ResponseWriter, r *http.Request, status int, err error) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]any{"error": err.Error()})
}
type principalKey struct{}
// WithPrincipal attaches the authenticated admin user to the request context.
// Called by the auth middleware wired in main.
func WithPrincipal(ctx context.Context, sub string) context.Context {
return context.WithValue(ctx, principalKey{}, sub)
}
func principalFrom(ctx context.Context) string {
if v, ok := ctx.Value(principalKey{}).(string); ok {
return v
}
return "unknown"
}

View file

@ -0,0 +1,182 @@
package httpapi
import (
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"basicstack.de/proofedmesh/services/admin-api/internal/store"
)
func newDeps(t *testing.T) Dependencies {
t.Helper()
return Dependencies{
Store: store.NewMemory(),
Now: func() time.Time { return time.Date(2026, 10, 25, 12, 0, 0, 0, time.UTC) },
}
}
func newMux(deps Dependencies) http.Handler {
mux := http.NewServeMux()
Mount(mux, deps)
// inject a principal for tests
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := WithPrincipal(r.Context(), "test-admin")
mux.ServeHTTP(w, r.WithContext(ctx))
})
}
func TestList_Empty(t *testing.T) {
srv := httptest.NewServer(newMux(newDeps(t)))
defer srv.Close()
resp, err := http.Get(srv.URL + "/admin/per-diem-rates/")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
t.Fatalf("status %d", resp.StatusCode)
}
}
func TestCreateAndGet_JSON(t *testing.T) {
deps := newDeps(t)
srv := httptest.NewServer(newMux(deps))
defer srv.Close()
body := `{
"country":"DE","currency":"EUR",
"full_day":"28.00","partial_day_min8h":"14.00",
"arrival_day":"14.00","departure_day":"14.00",
"valid_from":"2026-01-01T00:00:00Z",
"source_regulation":"BMF 2026"
}`
req, _ := http.NewRequest("POST", srv.URL+"/admin/per-diem-rates/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated {
t.Fatalf("expected 201, got %d", resp.StatusCode)
}
var out map[string]map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
rateID, _ := out["rate"]["rate_id"].(string)
if rateID == "" {
t.Fatalf("no rate_id in response: %v", out)
}
// Fetch it back
resp2, err := http.Get(srv.URL + "/admin/per-diem-rates/" + rateID)
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != 200 {
t.Fatalf("get status %d", resp2.StatusCode)
}
}
func TestCreate_InvalidBody_Rejected(t *testing.T) {
srv := httptest.NewServer(newMux(newDeps(t)))
defer srv.Close()
body := `{"country":"XYZ","currency":"EUR","full_day":"28.00","partial_day_min8h":"14.00","arrival_day":"14.00","departure_day":"14.00","valid_from":"2026-01-01T00:00:00Z","source_regulation":"BMF 2026"}`
req, _ := http.NewRequest("POST", srv.URL+"/admin/per-diem-rates/", strings.NewReader(body))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != 400 {
t.Fatalf("expected 400, got %d", resp.StatusCode)
}
}
func TestGet_NotFound(t *testing.T) {
srv := httptest.NewServer(newMux(newDeps(t)))
defer srv.Close()
resp, err := http.Get(srv.URL + "/admin/per-diem-rates/PDR-999")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusNotFound {
t.Fatalf("expected 404, got %d", resp.StatusCode)
}
}
func TestRetire_UpdatesValidTo(t *testing.T) {
deps := newDeps(t)
srv := httptest.NewServer(newMux(deps))
defer srv.Close()
createBody := `{"country":"DE","currency":"EUR","full_day":"28.00","partial_day_min8h":"14.00","arrival_day":"14.00","departure_day":"14.00","valid_from":"2026-01-01T00:00:00Z","source_regulation":"BMF 2026"}`
req, _ := http.NewRequest("POST", srv.URL+"/admin/per-diem-rates/", strings.NewReader(createBody))
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
var out map[string]map[string]any
_ = json.NewDecoder(resp.Body).Decode(&out)
rateID := out["rate"]["rate_id"].(string)
retireBody := `{"valid_to":"2026-06-30"}`
req2, _ := http.NewRequest("POST", srv.URL+"/admin/per-diem-rates/"+rateID+"/retire", strings.NewReader(retireBody))
req2.Header.Set("Content-Type", "application/json")
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != 200 {
t.Fatalf("retire: expected 200, got %d", resp2.StatusCode)
}
var out2 map[string]map[string]any
_ = json.NewDecoder(resp2.Body).Decode(&out2)
if _, ok := out2["rate"]["valid_to"]; !ok {
t.Fatal("expected valid_to to be set after retire")
}
}
func TestOverlap_Rejected(t *testing.T) {
srv := httptest.NewServer(newMux(newDeps(t)))
defer srv.Close()
body := func(validFrom string, validTo *string) string {
base := `{"country":"DE","currency":"EUR","full_day":"28.00","partial_day_min8h":"14.00","arrival_day":"14.00","departure_day":"14.00","valid_from":"` + validFrom + `T00:00:00Z","source_regulation":"BMF 2026"`
if validTo != nil {
base += `,"valid_to":"` + *validTo + `T00:00:00Z"`
}
return base + "}"
}
vt := "2026-12-31"
req1, _ := http.NewRequest("POST", srv.URL+"/admin/per-diem-rates/", strings.NewReader(body("2026-01-01", &vt)))
req1.Header.Set("Content-Type", "application/json")
resp1, err := http.DefaultClient.Do(req1)
if err != nil {
t.Fatal(err)
}
defer resp1.Body.Close()
if resp1.StatusCode != http.StatusCreated {
t.Fatalf("first create expected 201, got %d", resp1.StatusCode)
}
req2, _ := http.NewRequest("POST", srv.URL+"/admin/per-diem-rates/", strings.NewReader(body("2026-12-31", nil)))
req2.Header.Set("Content-Type", "application/json")
resp2, err := http.DefaultClient.Do(req2)
if err != nil {
t.Fatal(err)
}
defer resp2.Body.Close()
if resp2.StatusCode != http.StatusConflict {
t.Fatalf("overlapping create: expected 409, got %d", resp2.StatusCode)
}
}

View file

@ -0,0 +1,98 @@
// Package model defines the Rate value type and its invariants, shared by the
// HTTP layer and the storage layer. All amounts are decimal strings matching
// the "^-?[0-9]+\\.[0-9]{2}$" pattern used across ProofedMesh schemas; we
// deliberately do not reach for float.
package model
import (
"errors"
"fmt"
"regexp"
"time"
)
var (
countryPattern = regexp.MustCompile(`^[A-Z]{2}$`)
currencyPattern = regexp.MustCompile(`^[A-Z]{3}$`)
amountPattern = regexp.MustCompile(`^[0-9]+\.[0-9]{2}$`)
sha256Pattern = regexp.MustCompile(`^sha256:[0-9a-f]{64}$`)
)
// Rate mirrors a row in per_diem_rates. Monetary fields are decimal strings
// e.g. "28.00". Core business fields are set at creation and are immutable
// afterwards (enforced both by the Postgres trigger and by the admin API).
type Rate struct {
RateID string `json:"rate_id"`
Country string `json:"country"` // ISO 3166-1 alpha-2
City *string `json:"city,omitempty"` // optional
Currency string `json:"currency"` // ISO 4217
FullDay string `json:"full_day"`
PartialDayMin8h string `json:"partial_day_min8h"`
ArrivalDay string `json:"arrival_day"`
DepartureDay string `json:"departure_day"`
ValidFrom time.Time `json:"valid_from"`
ValidTo *time.Time `json:"valid_to,omitempty"`
SourceRegulation string `json:"source_regulation"`
CreatedBy string `json:"created_by"`
CreatedAt time.Time `json:"created_at"`
RateChangeAttestationRef *string `json:"rate_change_attestation_ref,omitempty"`
}
// NewRateInput carries the fields a caller may supply when creating a new rate.
// RateID, CreatedBy, CreatedAt and the attestation ref are assigned by the API,
// not the caller.
type NewRateInput struct {
Country string `json:"country"`
City *string `json:"city,omitempty"`
Currency string `json:"currency"`
FullDay string `json:"full_day"`
PartialDayMin8h string `json:"partial_day_min8h"`
ArrivalDay string `json:"arrival_day"`
DepartureDay string `json:"departure_day"`
ValidFrom time.Time `json:"valid_from"`
ValidTo *time.Time `json:"valid_to,omitempty"`
SourceRegulation string `json:"source_regulation"`
}
// Validate checks the invariants that are enforced by the schema and that we
// want to fail-fast on in the API layer so the user sees a sensible error
// before we hit Postgres.
func (in NewRateInput) Validate() error {
if !countryPattern.MatchString(in.Country) {
return fmt.Errorf("country %q must be ISO 3166-1 alpha-2 (two uppercase letters)", in.Country)
}
if in.City != nil && (*in.City == "" || len(*in.City) > 100) {
return errors.New("city, if set, must be 1..100 characters")
}
if !currencyPattern.MatchString(in.Currency) {
return fmt.Errorf("currency %q must be ISO 4217 (three uppercase letters)", in.Currency)
}
for _, p := range []struct {
name string
value string
}{
{"full_day", in.FullDay},
{"partial_day_min8h", in.PartialDayMin8h},
{"arrival_day", in.ArrivalDay},
{"departure_day", in.DepartureDay},
} {
if !amountPattern.MatchString(p.value) {
return fmt.Errorf("%s %q must be a decimal string with two decimals (e.g. '28.00')", p.name, p.value)
}
}
if in.ValidFrom.IsZero() {
return errors.New("valid_from is required")
}
if in.ValidTo != nil && in.ValidTo.Before(in.ValidFrom) {
return errors.New("valid_to must not be before valid_from")
}
if l := len(in.SourceRegulation); l < 1 || l > 500 {
return errors.New("source_regulation must be 1..500 characters")
}
return nil
}
// IsAttestationRef returns true for a well-formed sha256: content hash.
func IsAttestationRef(ref string) bool {
return sha256Pattern.MatchString(ref)
}

View file

@ -0,0 +1,148 @@
package store
import (
"context"
"fmt"
"sort"
"sync"
"time"
"basicstack.de/proofedmesh/services/admin-api/internal/model"
)
// Memory is a thread-safe in-memory implementation of RateStore. It is the
// fastest path for tests and the dev loop (`-store=memory`). Production uses
// the Postgres implementation.
type Memory struct {
mu sync.Mutex
rates map[string]model.Rate
seq int
}
func NewMemory() *Memory {
return &Memory{rates: make(map[string]model.Rate)}
}
func (m *Memory) List(ctx context.Context, f ListFilter) ([]model.Rate, error) {
m.mu.Lock()
defer m.mu.Unlock()
today := time.Now().UTC().Truncate(24 * time.Hour)
out := make([]model.Rate, 0, len(m.rates))
for _, r := range m.rates {
if f.Country != "" && r.Country != f.Country {
continue
}
if f.ActiveOnly {
if r.ValidTo != nil && r.ValidTo.Before(today) {
continue
}
}
out = append(out, r)
}
sort.Slice(out, func(i, j int) bool { return out[i].RateID < out[j].RateID })
return out, nil
}
func (m *Memory) Get(ctx context.Context, rateID string) (model.Rate, error) {
m.mu.Lock()
defer m.mu.Unlock()
r, ok := m.rates[rateID]
if !ok {
return model.Rate{}, ErrNotFound
}
return r, nil
}
func (m *Memory) Create(ctx context.Context, in model.NewRateInput, createdBy string, now time.Time) (model.Rate, error) {
m.mu.Lock()
defer m.mu.Unlock()
if err := checkOverlap(m.rates, in); err != nil {
return model.Rate{}, err
}
m.seq++
r := model.Rate{
RateID: fmt.Sprintf("PDR-%d", m.seq),
Country: in.Country,
City: in.City,
Currency: in.Currency,
FullDay: in.FullDay,
PartialDayMin8h: in.PartialDayMin8h,
ArrivalDay: in.ArrivalDay,
DepartureDay: in.DepartureDay,
ValidFrom: in.ValidFrom,
ValidTo: in.ValidTo,
SourceRegulation: in.SourceRegulation,
CreatedBy: createdBy,
CreatedAt: now,
}
m.rates[r.RateID] = r
return r, nil
}
func (m *Memory) Retire(ctx context.Context, rateID string, validTo time.Time, performedBy string, now time.Time) (model.Rate, error) {
m.mu.Lock()
defer m.mu.Unlock()
r, ok := m.rates[rateID]
if !ok {
return model.Rate{}, ErrNotFound
}
today := now.UTC().Truncate(24 * time.Hour)
if r.ValidTo != nil && r.ValidTo.Before(today) {
return model.Rate{}, ErrAlreadyRetired
}
if validTo.Before(r.ValidFrom) {
return model.Rate{}, fmt.Errorf("retire: valid_to %s is before valid_from %s", validTo.Format("2006-01-02"), r.ValidFrom.Format("2006-01-02"))
}
cpy := r
cpy.ValidTo = &validTo
m.rates[rateID] = cpy
return cpy, nil
}
func (m *Memory) AttachAttestation(ctx context.Context, rateID string, attestationRef string) error {
m.mu.Lock()
defer m.mu.Unlock()
r, ok := m.rates[rateID]
if !ok {
return ErrNotFound
}
if !model.IsAttestationRef(attestationRef) {
return fmt.Errorf("attestation ref %q is not a sha256: content hash", attestationRef)
}
cpy := r
cpy.RateChangeAttestationRef = &attestationRef
m.rates[rateID] = cpy
return nil
}
func checkOverlap(existing map[string]model.Rate, in model.NewRateInput) error {
cityKey := ""
if in.City != nil {
cityKey = *in.City
}
newEnd := time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC)
if in.ValidTo != nil {
newEnd = *in.ValidTo
}
for _, r := range existing {
if r.Country != in.Country {
continue
}
rc := ""
if r.City != nil {
rc = *r.City
}
if rc != cityKey {
continue
}
rEnd := time.Date(9999, 12, 31, 0, 0, 0, 0, time.UTC)
if r.ValidTo != nil {
rEnd = *r.ValidTo
}
// Overlapping ranges? Inclusive on both ends.
if !in.ValidFrom.After(rEnd) && !r.ValidFrom.After(newEnd) {
return ErrOverlap
}
}
return nil
}

View file

@ -0,0 +1,138 @@
package store
import (
"context"
"errors"
"testing"
"time"
"basicstack.de/proofedmesh/services/admin-api/internal/model"
)
func d(s string) time.Time {
t, err := time.Parse("2006-01-02", s)
if err != nil {
panic(err)
}
return t
}
func rateDE(validFrom, validTo string) model.NewRateInput {
in := model.NewRateInput{
Country: "DE", Currency: "EUR",
FullDay: "28.00", PartialDayMin8h: "14.00",
ArrivalDay: "14.00", DepartureDay: "14.00",
ValidFrom: d(validFrom),
SourceRegulation: "test",
}
if validTo != "" {
t := d(validTo)
in.ValidTo = &t
}
return in
}
func TestMemory_CreateAndGet(t *testing.T) {
m := NewMemory()
ctx := context.Background()
r, err := m.Create(ctx, rateDE("2026-01-01", ""), "admin", time.Now())
if err != nil {
t.Fatalf("create: %v", err)
}
if r.RateID != "PDR-1" {
t.Errorf("expected PDR-1, got %s", r.RateID)
}
got, err := m.Get(ctx, "PDR-1")
if err != nil {
t.Fatalf("get: %v", err)
}
if got.FullDay != "28.00" {
t.Errorf("roundtrip full_day: %s", got.FullDay)
}
}
func TestMemory_Get_NotFound(t *testing.T) {
m := NewMemory()
_, err := m.Get(context.Background(), "PDR-999")
if !errors.Is(err, ErrNotFound) {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
func TestMemory_OverlapRejected(t *testing.T) {
m := NewMemory()
ctx := context.Background()
if _, err := m.Create(ctx, rateDE("2026-01-01", "2026-12-31"), "admin", time.Now()); err != nil {
t.Fatalf("first create: %v", err)
}
// Overlap on the last day of 2026
_, err := m.Create(ctx, rateDE("2026-12-31", ""), "admin", time.Now())
if !errors.Is(err, ErrOverlap) {
t.Fatalf("expected ErrOverlap, got %v", err)
}
}
func TestMemory_RetireChangesValidTo(t *testing.T) {
m := NewMemory()
ctx := context.Background()
r, _ := m.Create(ctx, rateDE("2026-01-01", ""), "admin", time.Now())
updated, err := m.Retire(ctx, r.RateID, d("2026-06-30"), "admin2", time.Now())
if err != nil {
t.Fatalf("retire: %v", err)
}
if updated.ValidTo == nil || !updated.ValidTo.Equal(d("2026-06-30")) {
t.Errorf("valid_to not set to 2026-06-30: %v", updated.ValidTo)
}
}
func TestMemory_Retire_BeforeValidFrom_Rejected(t *testing.T) {
m := NewMemory()
ctx := context.Background()
r, _ := m.Create(ctx, rateDE("2026-01-01", ""), "admin", time.Now())
_, err := m.Retire(ctx, r.RateID, d("2025-12-01"), "admin2", time.Now())
if err == nil {
t.Fatal("expected error for valid_to before valid_from")
}
}
func TestMemory_AttachAttestation(t *testing.T) {
m := NewMemory()
ctx := context.Background()
r, _ := m.Create(ctx, rateDE("2026-01-01", ""), "admin", time.Now())
err := m.AttachAttestation(ctx, r.RateID, "sha256:0000000000000000000000000000000000000000000000000000000000000001")
if err != nil {
t.Fatalf("attach: %v", err)
}
got, _ := m.Get(ctx, r.RateID)
if got.RateChangeAttestationRef == nil {
t.Fatal("expected attestation ref to be set")
}
}
func TestMemory_AttachAttestation_BadRef_Rejected(t *testing.T) {
m := NewMemory()
ctx := context.Background()
r, _ := m.Create(ctx, rateDE("2026-01-01", ""), "admin", time.Now())
err := m.AttachAttestation(ctx, r.RateID, "not-a-hash")
if err == nil {
t.Fatal("expected error for malformed attestation ref")
}
}
func TestMemory_List_FilterByCountry(t *testing.T) {
m := NewMemory()
ctx := context.Background()
_, _ = m.Create(ctx, rateDE("2026-01-01", ""), "admin", time.Now())
fr := rateDE("2026-01-01", "")
fr.Country = "FR"
_, _ = m.Create(ctx, fr, "admin", time.Now())
all, _ := m.List(ctx, ListFilter{})
if len(all) != 2 {
t.Fatalf("expected 2 total, got %d", len(all))
}
de, _ := m.List(ctx, ListFilter{Country: "DE"})
if len(de) != 1 || de[0].Country != "DE" {
t.Fatalf("country filter failed: %+v", de)
}
}

View file

@ -0,0 +1,39 @@
package store
import (
"context"
"errors"
"time"
"basicstack.de/proofedmesh/services/admin-api/internal/model"
)
// Postgres is the production RateStore. **Skeleton:** methods return
// errPGUnimplemented. When wiring, swap this struct's internals for a
// pgxpool.Pool and real queries. The SQL is already in db/trust/migrations/
// and the schema enforces every invariant that Memory enforces.
type Postgres struct {
// pool *pgxpool.Pool // kept out of go.mod in the skeleton
}
var errPGUnimplemented = errors.New("postgres store is a skeleton; use NewMemory for now")
func NewPostgres( /* conn string, logger */ ) (*Postgres, error) {
return &Postgres{}, nil
}
func (p *Postgres) List(ctx context.Context, f ListFilter) ([]model.Rate, error) {
return nil, errPGUnimplemented
}
func (p *Postgres) Get(ctx context.Context, rateID string) (model.Rate, error) {
return model.Rate{}, errPGUnimplemented
}
func (p *Postgres) Create(ctx context.Context, in model.NewRateInput, createdBy string, now time.Time) (model.Rate, error) {
return model.Rate{}, errPGUnimplemented
}
func (p *Postgres) Retire(ctx context.Context, rateID string, validTo time.Time, performedBy string, now time.Time) (model.Rate, error) {
return model.Rate{}, errPGUnimplemented
}
func (p *Postgres) AttachAttestation(ctx context.Context, rateID string, attestationRef string) error {
return errPGUnimplemented
}

View file

@ -0,0 +1,50 @@
// Package store abstracts the per_diem_rates storage so the HTTP layer can be
// tested without a real Postgres and so the Postgres backend can be swapped
// against an in-memory fake for dev loops.
package store
import (
"context"
"errors"
"time"
"basicstack.de/proofedmesh/services/admin-api/internal/model"
)
// ErrNotFound is returned by Get and Retire when the rate_id does not exist.
var ErrNotFound = errors.New("per_diem_rate: not found")
// ErrOverlap is returned by Create when the new rate would overlap an existing
// (country, city) range. Mirrors the Postgres EXCLUDE constraint.
var ErrOverlap = errors.New("per_diem_rate: overlaps an existing active range for (country, city)")
// ErrAlreadyRetired is returned by Retire when the rate is already retired
// (valid_to IS NOT NULL and in the past).
var ErrAlreadyRetired = errors.New("per_diem_rate: already retired")
// ListFilter narrows a List call. Zero value lists everything.
type ListFilter struct {
Country string // "" = any
ActiveOnly bool // if true, only rates with valid_to IS NULL or valid_to >= today
}
// RateStore is implemented by both the in-memory store (for tests + dev) and
// the Postgres store (for prod). All methods are side-effect-free apart from
// their obvious effect and all honour ctx cancellation.
type RateStore interface {
List(ctx context.Context, f ListFilter) ([]model.Rate, error)
Get(ctx context.Context, rateID string) (model.Rate, error)
// Create inserts a new rate, returning the finalised row (rate_id is
// assigned by the backend). The attestation ref is set later via
// AttachAttestation when the Signing Service has produced it.
Create(ctx context.Context, in model.NewRateInput, createdBy string, now time.Time) (model.Rate, error)
// Retire sets valid_to on the rate; subsequent lookups by valid_on past
// that date must not return it.
Retire(ctx context.Context, rateID string, validTo time.Time, performedBy string, now time.Time) (model.Rate, error)
// AttachAttestation attaches the signed attestation reference to a rate.
// Called after Create or Retire once the Signing Service responded.
AttachAttestation(ctx context.Context, rateID string, attestationRef string) error
}

View file

@ -0,0 +1,41 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Rate {{.Rate.RateID}}</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; max-width: 700px; }
dt { font-weight: 600; margin-top: 0.5rem; }
dd { margin-left: 1rem; }
.amount { font-variant-numeric: tabular-nums; }
</style>
</head>
<body>
<p><a href="/admin/per-diem-rates/">&larr; Zurück zur Liste</a></p>
<h1>{{.Rate.RateID}}</h1>
<dl>
<dt>Land / Stadt</dt><dd>{{.Rate.Country}}{{if .Rate.City}} / {{.Rate.City}}{{end}}</dd>
<dt>Währung</dt><dd>{{.Rate.Currency}}</dd>
<dt>Voller Tag</dt><dd class="amount">{{.Rate.FullDay}}</dd>
<dt>8h+ Teiltag</dt><dd class="amount">{{.Rate.PartialDayMin8h}}</dd>
<dt>Anreisetag</dt><dd class="amount">{{.Rate.ArrivalDay}}</dd>
<dt>Abreisetag</dt><dd class="amount">{{.Rate.DepartureDay}}</dd>
<dt>Gültig ab</dt><dd>{{.Rate.ValidFrom.Format "2006-01-02"}}</dd>
<dt>Gültig bis</dt><dd>{{if .Rate.ValidTo}}{{.Rate.ValidTo.Format "2006-01-02"}}{{else}}—{{end}}</dd>
<dt>Quelle</dt><dd>{{.Rate.SourceRegulation}}</dd>
<dt>Angelegt von</dt><dd>{{.Rate.CreatedBy}} am {{.Rate.CreatedAt.Format "2006-01-02 15:04 UTC"}}</dd>
<dt>Attestations-Ref</dt><dd>{{if .Rate.RateChangeAttestationRef}}<code>{{.Rate.RateChangeAttestationRef}}</code>{{else}}<em>(ausstehend)</em>{{end}}</dd>
</dl>
{{if not .Rate.ValidTo}}
<h2>Rate ablaufen lassen</h2>
<form method="post" action="/admin/per-diem-rates/{{.Rate.RateID}}/retire">
<label>Gültig bis (einschließlich) <input type="date" name="valid_to" required></label>
<button type="submit">Retire</button>
</form>
<p><small>Hinweis: Die Rate bleibt für historische Berechnungen bestehen; nur neu generierte Workflows nach <code>valid_to</code> nutzen eine Nachfolge-Rate.</small></p>
{{else}}
<p><em>Rate ist bereits bis {{.Rate.ValidTo.Format "2006-01-02"}} begrenzt — keine weitere Pflegeaktion möglich.</em></p>
{{end}}
</body>
</html>

View file

@ -0,0 +1,95 @@
<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<title>Per-Diem-Pauschalen</title>
<style>
body { font-family: system-ui, sans-serif; margin: 2rem; max-width: 1100px; }
table { border-collapse: collapse; width: 100%; }
th, td { border: 1px solid #ddd; padding: 0.4rem 0.6rem; text-align: left; }
th { background: #f3f4f6; }
.retired { color: #9ca3af; }
form.inline { display: inline; }
.amount { text-align: right; font-variant-numeric: tabular-nums; }
nav a { margin-right: 1rem; }
</style>
</head>
<body>
<nav>
<a href="/admin/per-diem-rates/">Alle</a>
<a href="/admin/per-diem-rates/?active_only=true">Nur aktive</a>
</nav>
<h1>Per-Diem-Pauschalen</h1>
<form method="get" action="/admin/per-diem-rates/">
<label>Land (ISO alpha-2)
<input name="country" value="{{.Filter.Country}}" maxlength="2" pattern="[A-Z]{2}">
</label>
<label>
<input type="checkbox" name="active_only" value="true" {{if .Filter.ActiveOnly}}checked{{end}}>
nur aktive
</label>
<button type="submit">Filtern</button>
</form>
<table>
<thead>
<tr>
<th>Rate</th>
<th>Land / Stadt</th>
<th>Währung</th>
<th class="amount">Voll</th>
<th class="amount">8h+</th>
<th class="amount">Anreise</th>
<th class="amount">Abreise</th>
<th>Gültig ab</th>
<th>Gültig bis</th>
<th>Quelle</th>
</tr>
</thead>
<tbody>
{{range .Rates}}
<tr {{if .ValidTo}}class="retired"{{end}}>
<td><a href="/admin/per-diem-rates/{{.RateID}}">{{.RateID}}</a></td>
<td>{{.Country}}{{if .City}} / {{.City}}{{end}}</td>
<td>{{.Currency}}</td>
<td class="amount">{{.FullDay}}</td>
<td class="amount">{{.PartialDayMin8h}}</td>
<td class="amount">{{.ArrivalDay}}</td>
<td class="amount">{{.DepartureDay}}</td>
<td>{{.ValidFrom.Format "2006-01-02"}}</td>
<td>{{if .ValidTo}}{{.ValidTo.Format "2006-01-02"}}{{else}}—{{end}}</td>
<td>{{.SourceRegulation}}</td>
</tr>
{{else}}
<tr><td colspan="10"><em>Keine Rates gefunden.</em></td></tr>
{{end}}
</tbody>
</table>
<h2>Neue Rate anlegen</h2>
<form method="post" action="/admin/per-diem-rates/">
<p>
<label>Land <input name="country" required maxlength="2" pattern="[A-Z]{2}" placeholder="DE"></label>
<label>Stadt (optional) <input name="city" maxlength="100"></label>
<label>Währung <input name="currency" required maxlength="3" pattern="[A-Z]{3}" value="EUR"></label>
</p>
<p>
<label>Voller Tag <input name="full_day" required pattern="[0-9]+\.[0-9]{2}" placeholder="28.00"></label>
<label>8h+ <input name="partial_day_min8h" required pattern="[0-9]+\.[0-9]{2}" placeholder="14.00"></label>
<label>Anreise <input name="arrival_day" required pattern="[0-9]+\.[0-9]{2}" placeholder="14.00"></label>
<label>Abreise <input name="departure_day" required pattern="[0-9]+\.[0-9]{2}" placeholder="14.00"></label>
</p>
<p>
<label>Gültig ab <input name="valid_from" required type="date"></label>
<label>Gültig bis (optional) <input name="valid_to" type="date"></label>
</p>
<p>
<label>Quelle (BMF-Schreiben etc.) <br>
<input name="source_regulation" required maxlength="500" size="80" placeholder="BMF-Schreiben 2026-11-15 Az. IV C 5 - S 2353/19/10011 :005">
</label>
</p>
<button type="submit">Anlegen</button>
</form>
</body>
</html>