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)
This commit is contained in:
parent
d56f918c69
commit
65514559dc
14 changed files with 1251 additions and 0 deletions
104
services/admin-api/README.md
Normal file
104
services/admin-api/README.md
Normal 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`.
|
||||||
BIN
services/admin-api/admin-api
Normal file
BIN
services/admin-api/admin-api
Normal file
Binary file not shown.
86
services/admin-api/cmd/admin-api/main.go
Normal file
86
services/admin-api/cmd/admin-api/main.go
Normal 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)
|
||||||
|
}
|
||||||
3
services/admin-api/go.mod
Normal file
3
services/admin-api/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module basicstack.de/proofedmesh/services/admin-api
|
||||||
|
|
||||||
|
go 1.23
|
||||||
49
services/admin-api/internal/attest/ratechange.go
Normal file
49
services/admin-api/internal/attest/ratechange.go
Normal 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
|
||||||
|
}
|
||||||
218
services/admin-api/internal/httpapi/rates.go
Normal file
218
services/admin-api/internal/httpapi/rates.go
Normal 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"
|
||||||
|
}
|
||||||
182
services/admin-api/internal/httpapi/rates_test.go
Normal file
182
services/admin-api/internal/httpapi/rates_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
98
services/admin-api/internal/model/rate.go
Normal file
98
services/admin-api/internal/model/rate.go
Normal 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)
|
||||||
|
}
|
||||||
148
services/admin-api/internal/store/memory.go
Normal file
148
services/admin-api/internal/store/memory.go
Normal 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
|
||||||
|
}
|
||||||
138
services/admin-api/internal/store/memory_test.go
Normal file
138
services/admin-api/internal/store/memory_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
39
services/admin-api/internal/store/postgres.go
Normal file
39
services/admin-api/internal/store/postgres.go
Normal 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
|
||||||
|
}
|
||||||
50
services/admin-api/internal/store/store.go
Normal file
50
services/admin-api/internal/store/store.go
Normal 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
|
||||||
|
}
|
||||||
41
services/admin-api/web/templates/per_diem_rates_detail.html
Normal file
41
services/admin-api/web/templates/per_diem_rates_detail.html
Normal 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/">← 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>
|
||||||
95
services/admin-api/web/templates/per_diem_rates_list.html
Normal file
95
services/admin-api/web/templates/per_diem_rates_list.html
Normal 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>
|
||||||
Loading…
Add table
Reference in a new issue