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