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