147 lines
4.5 KiB
Go
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
|
|
}
|