From 3f466dfae83f996f63033a4373aee5dae6a63c6c74e3612f5ffed974d0cbbb2f Mon Sep 17 00:00:00 2001 From: CTO Agent Date: Sun, 25 Oct 2026 17:07:55 +0000 Subject: [PATCH] services: add per-diem-calculator Go skeleton (deterministic compute core + 5 unit tests, HTTP wire stubbed); README reflects new layout --- README.md | 20 ++- services/per-diem-calculator/README.md | 44 ++++++ .../cmd/calculator/main.go | 25 +++ services/per-diem-calculator/go.mod | 3 + .../per-diem-calculator/internal/calc/calc.go | 147 ++++++++++++++++++ .../internal/calc/calc_test.go | 86 ++++++++++ .../internal/rates/rates.go | 24 +++ 7 files changed, 341 insertions(+), 8 deletions(-) create mode 100644 services/per-diem-calculator/README.md create mode 100644 services/per-diem-calculator/cmd/calculator/main.go create mode 100644 services/per-diem-calculator/go.mod create mode 100644 services/per-diem-calculator/internal/calc/calc.go create mode 100644 services/per-diem-calculator/internal/calc/calc_test.go create mode 100644 services/per-diem-calculator/internal/rates/rates.go diff --git a/README.md b/README.md index afab0a4..113beca 100644 --- a/README.md +++ b/README.md @@ -56,20 +56,24 @@ docs/ 0002-session-scope-token-format.md 0003-pii-retention-policy.md schemas/ - README.md # Schema-Index und Namenskonvention - expense/canonical/v1.0.0.json + README.md + expense/canonical/{v1.0.0,v1.1.0,v1.2.0}.json ds/expense/project_assignment/v1.0.0.json - sink/db/expense/v1.0.0.json + ds/receipts/v1.0.0.json + ds/per_diem/v1.0.0.json + sink/db/expense/{v1.0.0,v1.1.0,v1.2.0}.json policies/ - README.md # Policy-Klassen, Input-Verträge, Deployment - transformations/expense.rego (+ _test.rego) - sinks/expense_db_app.rego (+ _test.rego) - data_sources/expense_db_app.rego (+ _test.rego) + README.md + transformations/{expense,per_diem}.rego (+ _test.rego) + sinks/expense_db_app.rego (+ _test.rego) + data_sources/{expense_db_app,receipts,per_diem_rates}.rego (+ _test.rego) +services/ + per-diem-calculator/ # deterministic calculator (Go), currently skeleton .forgejo/workflows/ validate.yml # JSON-Schema (ajv) + Rego (opa fmt + opa test) ``` -Weitere Verzeichnisse (Services, Deployment-Manifeste, Senken-/Datenquellen-Adapter) folgen im Zuge der Architekturentwicklung. +Weitere Verzeichnisse (weitere Services, Deployment-Manifeste, Senken-/Datenquellen-Adapter) folgen im Zuge der Architekturentwicklung. ## Einstiegspunkte diff --git a/services/per-diem-calculator/README.md b/services/per-diem-calculator/README.md new file mode 100644 index 0000000..68ff470 --- /dev/null +++ b/services/per-diem-calculator/README.md @@ -0,0 +1,44 @@ +# per-diem-calculator + +Deterministic per-diem (Verpflegungsmehraufwand) calculator for ProofedMesh. + +**Deliberately not an LLM.** Pauschalen gehören nicht in Prompts — Reproduzierbarkeit, Nachvollziehbarkeit, Angriffsfläche (siehe [`docs/architecture/v1.1.md` §6.2](../../docs/architecture/v1.1.md#user-content-62-deterministische-regelwerke-am-beispiel-spesensätze-verpflegungsmehraufwand)). + +## Scope (skeleton) + +This directory currently holds the service **skeleton** — package layout, data types, and a calculation unit with BMF-style fixtures. The HTTP wire layer, Pocket-ID client, Session-Scope-Token acquisition, Receipt-Adapter integration, Signing-Service call, and Trillian attestation submission are stubs or absent. They will be added incrementally in the Phase-2 spike. + +## Layout + +``` +services/per-diem-calculator/ +├── README.md +├── go.mod +├── cmd/calculator/main.go — HTTP entrypoint stub +└── internal/ + ├── calc/ + │ ├── calc.go — deterministic calculation core + │ └── calc_test.go — table-driven tests with BMF fixtures + └── rates/ + └── rates.go — DataSource client stub (ds:per-diem-rates) +``` + +## Verträge + +- **Input:** `travel.plan` v1.0.0 — start_date, end_date, legs[]{country, from_date, to_date} — zu definieren als `schemas/travel/plan/v1.0.0.json` beim ersten tatsächlichen Lauf. +- **Rates-Lookup:** `ds:per-diem-rates` /rates mit `{country, valid_on}` je Reisetag (per Session-Scope-Token). Rückgabe-Schema `ds.per_diem.rate` v1.0.0. +- **Output:** `expense.canonical` v1.2.0 `expense_items[]` mit `type ∈ {PER_DIEM_ARRIVAL, PER_DIEM_DEPARTURE, PER_DIEM_FULL, PER_DIEM_PARTIAL}` und `computed_from` je Zeile. +- **Attestation:** DSSE-signiert durch Signing Service, Operation `expense.compute_per_diem`, Policy `proofedmesh.transformations.per_diem` muss `allow` zurückgeben. + +## Rundungskonvention + +Monetäre Werte als Dezimal-Strings. Beträge aus Rates werden 1:1 übernommen (keine Teilrundung — die BMF-Pauschalen sind Vollsätze). Summenbildung über alle Items passiert erst beim Mapping auf die Senke (siehe `agent-sink-mapping`). + +## Lokal bauen (sobald Code vollständig) + +```bash +go test ./... +go run ./cmd/calculator -help +``` + +Noch nicht lauffähig — Dependencies sind leer und `main` ist Stub. diff --git a/services/per-diem-calculator/cmd/calculator/main.go b/services/per-diem-calculator/cmd/calculator/main.go new file mode 100644 index 0000000..4bcf98f --- /dev/null +++ b/services/per-diem-calculator/cmd/calculator/main.go @@ -0,0 +1,25 @@ +// Package main is the entry point for the per-diem-calculator service. +// Current state: skeleton — HTTP wire, Pocket-ID auth, Rates fetch and +// Signing-Service call are stubs. The deterministic calculation core lives +// under internal/calc and is already covered by table-driven tests. +package main + +import ( + "flag" + "fmt" + "os" +) + +func main() { + addr := flag.String("addr", ":8080", "HTTP listen address") + showHelp := flag.Bool("help", false, "print help and exit") + flag.Parse() + + if *showHelp { + fmt.Fprintln(os.Stdout, "per-diem-calculator (skeleton)") + fmt.Fprintln(os.Stdout, " Not yet wired. See services/per-diem-calculator/README.md") + return + } + + fmt.Fprintf(os.Stdout, "per-diem-calculator skeleton would listen on %s — HTTP wiring intentionally absent in this skeleton commit.\n", *addr) +} diff --git a/services/per-diem-calculator/go.mod b/services/per-diem-calculator/go.mod new file mode 100644 index 0000000..a055688 --- /dev/null +++ b/services/per-diem-calculator/go.mod @@ -0,0 +1,3 @@ +module basicstack.de/proofedmesh/services/per-diem-calculator + +go 1.23 diff --git a/services/per-diem-calculator/internal/calc/calc.go b/services/per-diem-calculator/internal/calc/calc.go new file mode 100644 index 0000000..572c5e2 --- /dev/null +++ b/services/per-diem-calculator/internal/calc/calc.go @@ -0,0 +1,147 @@ +// Package calc computes per-diem (Verpflegungsmehraufwand) expense items +// deterministically from a travel plan and a set of applicable rates. +// +// This module is deliberately free of network calls and LLM dependencies. +// It is the single source of truth for the business logic that produces +// PER_DIEM_* items on the canonical expense structure. +package calc + +import ( + "errors" + "fmt" + "sort" + "time" +) + +// Leg describes a continuous presence in one country+optional city. +// Dates are inclusive on both ends. +type Leg struct { + Country string // ISO 3166-1 alpha-2 + City string // optional + FromDate time.Time // inclusive + ToDate time.Time // inclusive +} + +// Rate holds the applicable per-diem amounts for a day in a given country/city. +// All amount values are expressed as decimal strings matching the canonical +// money representation, e.g. "28.00". +type Rate struct { + RateID string + Country string + City string // optional + Currency string // ISO 4217 + FullDay string + PartialDayMin8h string + ArrivalDay string + DepartureDay string + ValidFrom time.Time + ValidTo *time.Time + SourceRegulation string +} + +// ItemType mirrors the enum values accepted by expense.canonical v1.2.0. +type ItemType string + +const ( + PerDiemArrival ItemType = "PER_DIEM_ARRIVAL" + PerDiemDeparture ItemType = "PER_DIEM_DEPARTURE" + PerDiemFull ItemType = "PER_DIEM_FULL" + PerDiemPartial ItemType = "PER_DIEM_PARTIAL" +) + +// Item is a single generated per-diem line. +type Item struct { + Type ItemType + Amount string // decimal string matching the Rate's currency + IncurredOn time.Time + RateID string // for traceability into the computed_from reference +} + +// RateProvider yields the applicable Rate for (country, date). Implementations +// typically wrap a ds:per-diem-rates query. The returned Rate must have +// valid_from <= day <= valid_to (or valid_to == nil). +type RateProvider interface { + RateFor(country string, day time.Time) (Rate, error) +} + +// Compute returns the per-diem items for the given travel legs. Legs must be +// sorted ascending by FromDate and non-overlapping; Compute does not merge +// overlaps. The business rules implemented here are a reasonable baseline for +// the German BMF model and need to be configured for other jurisdictions; +// introduce a strategy pattern once a second jurisdiction appears. +func Compute(legs []Leg, provider RateProvider) ([]Item, error) { + if len(legs) == 0 { + return nil, errors.New("no travel legs supplied") + } + if provider == nil { + return nil, errors.New("nil RateProvider") + } + + // Collect all distinct trip days in order. Multi-leg trips keep each day + // bound to the leg that contains it. + type dayLeg struct { + day time.Time + leg Leg + } + var days []dayLeg + for _, l := range legs { + if l.ToDate.Before(l.FromDate) { + return nil, fmt.Errorf("leg %s: to_date before from_date", l.Country) + } + for d := l.FromDate; !d.After(l.ToDate); d = d.AddDate(0, 0, 1) { + days = append(days, dayLeg{day: d, leg: l}) + } + } + sort.Slice(days, func(i, j int) bool { return days[i].day.Before(days[j].day) }) + + // With only one trip day, the German rule treats it as a partial day >8h + // requiring explicit absence proof. For the skeleton we categorise it as + // PER_DIEM_PARTIAL and let the compliance layer decide whether the + // absence condition held. + items := make([]Item, 0, len(days)) + if len(days) == 1 { + d := days[0] + rate, err := provider.RateFor(d.leg.Country, d.day) + if err != nil { + return nil, fmt.Errorf("rate lookup for %s on %s: %w", d.leg.Country, d.day.Format("2006-01-02"), err) + } + items = append(items, Item{ + Type: PerDiemPartial, + Amount: rate.PartialDayMin8h, + IncurredOn: d.day, + RateID: rate.RateID, + }) + return items, nil + } + + for i, d := range days { + rate, err := provider.RateFor(d.leg.Country, d.day) + if err != nil { + return nil, fmt.Errorf("rate lookup for %s on %s: %w", d.leg.Country, d.day.Format("2006-01-02"), err) + } + switch { + case i == 0: + items = append(items, Item{ + Type: PerDiemArrival, + Amount: rate.ArrivalDay, + IncurredOn: d.day, + RateID: rate.RateID, + }) + case i == len(days)-1: + items = append(items, Item{ + Type: PerDiemDeparture, + Amount: rate.DepartureDay, + IncurredOn: d.day, + RateID: rate.RateID, + }) + default: + items = append(items, Item{ + Type: PerDiemFull, + Amount: rate.FullDay, + IncurredOn: d.day, + RateID: rate.RateID, + }) + } + } + return items, nil +} diff --git a/services/per-diem-calculator/internal/calc/calc_test.go b/services/per-diem-calculator/internal/calc/calc_test.go new file mode 100644 index 0000000..29a81aa --- /dev/null +++ b/services/per-diem-calculator/internal/calc/calc_test.go @@ -0,0 +1,86 @@ +package calc + +import ( + "errors" + "reflect" + "testing" + "time" +) + +type staticProvider struct { + rate Rate +} + +func (p staticProvider) RateFor(country string, day time.Time) (Rate, error) { + if p.rate.Country != country { + return Rate{}, errors.New("country mismatch") + } + return p.rate, nil +} + +func d(s string) time.Time { + t, err := time.Parse("2006-01-02", s) + if err != nil { + panic(err) + } + return t +} + +func TestCompute_SingleDay_PartialRate(t *testing.T) { + legs := []Leg{{Country: "DE", FromDate: d("2026-10-02"), ToDate: d("2026-10-02")}} + rate := Rate{RateID: "PDR-42", Country: "DE", Currency: "EUR", + FullDay: "28.00", PartialDayMin8h: "14.00", + ArrivalDay: "14.00", DepartureDay: "14.00", + } + got, err := Compute(legs, staticProvider{rate: rate}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []Item{{Type: PerDiemPartial, Amount: "14.00", IncurredOn: d("2026-10-02"), RateID: "PDR-42"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %+v want %+v", got, want) + } +} + +func TestCompute_ThreeDayTrip_ArrivalFullDeparture(t *testing.T) { + legs := []Leg{{Country: "DE", FromDate: d("2026-10-01"), ToDate: d("2026-10-03")}} + rate := Rate{RateID: "PDR-42", Country: "DE", Currency: "EUR", + FullDay: "28.00", PartialDayMin8h: "14.00", + ArrivalDay: "14.00", DepartureDay: "14.00", + } + got, err := Compute(legs, staticProvider{rate: rate}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + want := []Item{ + {Type: PerDiemArrival, Amount: "14.00", IncurredOn: d("2026-10-01"), RateID: "PDR-42"}, + {Type: PerDiemFull, Amount: "28.00", IncurredOn: d("2026-10-02"), RateID: "PDR-42"}, + {Type: PerDiemDeparture, Amount: "14.00", IncurredOn: d("2026-10-03"), RateID: "PDR-42"}, + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("got %+v want %+v", got, want) + } +} + +func TestCompute_InvertedDates_Rejected(t *testing.T) { + legs := []Leg{{Country: "DE", FromDate: d("2026-10-05"), ToDate: d("2026-10-03")}} + _, err := Compute(legs, staticProvider{rate: Rate{Country: "DE"}}) + if err == nil { + t.Fatal("expected error for inverted dates") + } +} + +func TestCompute_EmptyLegs_Rejected(t *testing.T) { + _, err := Compute(nil, staticProvider{rate: Rate{Country: "DE"}}) + if err == nil { + t.Fatal("expected error for empty legs") + } +} + +func TestCompute_NilProvider_Rejected(t *testing.T) { + legs := []Leg{{Country: "DE", FromDate: d("2026-10-01"), ToDate: d("2026-10-03")}} + _, err := Compute(legs, nil) + if err == nil { + t.Fatal("expected error for nil provider") + } +} diff --git a/services/per-diem-calculator/internal/rates/rates.go b/services/per-diem-calculator/internal/rates/rates.go new file mode 100644 index 0000000..d5c892f --- /dev/null +++ b/services/per-diem-calculator/internal/rates/rates.go @@ -0,0 +1,24 @@ +// Package rates holds the Data-Source client stub for ds:per-diem-rates. +// Current state: skeleton — no HTTP transport, no Session-Scope-Token +// acquisition. The intent is to document the eventual signature and let +// the calc package compile against it. +package rates + +import ( + "errors" + "time" +) + +// Client exposes the one operation the calculator needs: fetch the applicable +// rate for a country+date. The real implementation will call +// ds:per-diem-rates /rates with a Session-Scope-Token and return the single +// applicable rate (filtering by city if provided). +type Client struct { + // BaseURL, HTTPClient, ScopeTokenFetcher — intentionally omitted in skeleton +} + +// RateFor is a stub that always returns a helpful error so tests that need a +// real provider substitute their own. +func (c *Client) RateFor(country string, day time.Time) (interface{}, error) { + return nil, errors.New("rates.Client is a skeleton; substitute a test or real provider") +}