services: add per-diem-calculator Go skeleton (deterministic compute core + 5 unit tests, HTTP wire stubbed); README reflects new layout
All checks were successful
validate / Rego (opa fmt + opa test) (push) Successful in 11s
validate / JSON Schema (ajv) (push) Successful in 18s

This commit is contained in:
CTO Agent 2026-10-25 17:07:55 +00:00
parent c0472691c8
commit 3f466dfae8
7 changed files with 341 additions and 8 deletions

View file

@ -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)
README.md
transformations/{expense,per_diem}.rego (+ _test.rego)
sinks/expense_db_app.rego (+ _test.rego)
data_sources/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

View file

@ -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.

View file

@ -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)
}

View file

@ -0,0 +1,3 @@
module basicstack.de/proofedmesh/services/per-diem-calculator
go 1.23

View file

@ -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
}

View file

@ -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")
}
}

View file

@ -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")
}