services: add per-diem-calculator Go skeleton (deterministic compute core + 5 unit tests, HTTP wire stubbed); README reflects new layout
This commit is contained in:
parent
c0472691c8
commit
3f466dfae8
7 changed files with 341 additions and 8 deletions
18
README.md
18
README.md
|
|
@ -56,20 +56,24 @@ docs/
|
||||||
0002-session-scope-token-format.md
|
0002-session-scope-token-format.md
|
||||||
0003-pii-retention-policy.md
|
0003-pii-retention-policy.md
|
||||||
schemas/
|
schemas/
|
||||||
README.md # Schema-Index und Namenskonvention
|
README.md
|
||||||
expense/canonical/v1.0.0.json
|
expense/canonical/{v1.0.0,v1.1.0,v1.2.0}.json
|
||||||
ds/expense/project_assignment/v1.0.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/
|
policies/
|
||||||
README.md # Policy-Klassen, Input-Verträge, Deployment
|
README.md
|
||||||
transformations/expense.rego (+ _test.rego)
|
transformations/{expense,per_diem}.rego (+ _test.rego)
|
||||||
sinks/expense_db_app.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/
|
.forgejo/workflows/
|
||||||
validate.yml # JSON-Schema (ajv) + Rego (opa fmt + opa test)
|
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
|
## Einstiegspunkte
|
||||||
|
|
||||||
|
|
|
||||||
44
services/per-diem-calculator/README.md
Normal file
44
services/per-diem-calculator/README.md
Normal 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.
|
||||||
25
services/per-diem-calculator/cmd/calculator/main.go
Normal file
25
services/per-diem-calculator/cmd/calculator/main.go
Normal 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)
|
||||||
|
}
|
||||||
3
services/per-diem-calculator/go.mod
Normal file
3
services/per-diem-calculator/go.mod
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
module basicstack.de/proofedmesh/services/per-diem-calculator
|
||||||
|
|
||||||
|
go 1.23
|
||||||
147
services/per-diem-calculator/internal/calc/calc.go
Normal file
147
services/per-diem-calculator/internal/calc/calc.go
Normal 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
|
||||||
|
}
|
||||||
86
services/per-diem-calculator/internal/calc/calc_test.go
Normal file
86
services/per-diem-calculator/internal/calc/calc_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
24
services/per-diem-calculator/internal/rates/rates.go
Normal file
24
services/per-diem-calculator/internal/rates/rates.go
Normal 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")
|
||||||
|
}
|
||||||
Loading…
Add table
Reference in a new issue