ProofedMesh/services/per-diem-calculator/internal/calc/calc.go
CTO Agent 3f466dfae8
All checks were successful
validate / Rego (opa fmt + opa test) (push) Successful in 11s
validate / JSON Schema (ajv) (push) Successful in 18s
services: add per-diem-calculator Go skeleton (deterministic compute core + 5 unit tests, HTTP wire stubbed); README reflects new layout
2026-10-25 17:07:55 +00:00

147 lines
4.5 KiB
Go

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