package accountant // Monatsabrechnung (Spec 5.3, Roadmap 6d Schritt 4): je Kunde die // Pauschale und je Posten (Mandant, Protokoll, Router über alle Payees und // Knoten) Klasse, Produkt, Betrag – als CSV für die Buchhaltung (Lexware // Office). Preise sind optional und nicht buchhaltungsrelevant. import ( "cmp" "context" "encoding/csv" "fmt" "io" "slices" "strings" "time" "sultan/pkg/ledger" ) // ItemFlatRate ist der Posten-Name der Pauschale eines Kunden. const ItemFlatRate = "Pauschale" // BillLine ist eine Zeile der Monatsabrechnung. type BillLine struct { Month string // JJJJ-MM Customer string // "" = Kunde unbekannt CustomerName string Group string Cycle string // ledger.Cycle* Item string // ItemFlatRate oder // Hosts string // durch Leerzeichen getrennt Tenant string Class string // ledger.Class*; "" = nicht zugeordnet Product string From, To string // JJJJ-MM-TT; To leer = läuft weiter Amount *int64 // Monatsbetrag in Cent; nil = unbekannt (kein Preis/keine Zuordnung) PeriodAmount *int64 // Rechnungsbetrag je Zahlungsperiode (×3 bzw. ×12); nil = unbekannt Shadow *int64 // Schattenpreis (free/internal): was es kosten würde Note string CustomerNote string } // Billing berechnet die Abrechnung eines Monats (JJJJ-MM, Ortszeit) aus der // ganzen Historie. func Billing(ctx context.Context, l *ledger.Ledger, month string) ([]BillLine, error) { spans, err := l.RouteHistory(ctx, ledger.HistoryFilter{}) if err != nil { return nil, err } return billFrom(ctx, l, month, spans, time.Local) } type item struct { key ItemKey first time.Time ended time.Time // Nullzeit = läuft open bool // mindestens ein Ort läuft noch latest ledger.RouteSpan } func cycleFactor(c string) (int64, string) { switch c { case ledger.CycleMonthly: return 1, "" case ledger.CycleQuarterly: return 3, "Quartalszahler: Rechnung = 3 × Monatsbetrag" case ledger.CycleYearly: return 12, "Jahreszahler: Monatsbetrag = 1/12 der Jahresrechnung" } return 0, "noch nicht in Zahlweise (m, q, j) eingeordnet!" } func ptr(v int64) *int64 { return &v } func billFrom(ctx context.Context, l *ledger.Ledger, month string, spans []ledger.RouteSpan, loc *time.Location) ([]BillLine, error) { start, err := time.ParseInLocation("2006-01", month, loc) if err != nil || !ledger.ValidMonth(month) { return nil, fmt.Errorf("Monat %q ungültig (JJJJ-MM)", month) } end := start.AddDate(0, 1, 0) daysInMonth := int64(end.Add(-time.Hour).Day()) customers, err := l.ListCustomers(ctx) if err != nil { return nil, err } byID := map[string]ledger.Customer{} for _, c := range customers { byID[c.ID] = c } resolver, err := l.CustomerResolver(ctx) if err != nil { return nil, err } cat, err := l.Catalog(ctx) if err != nil { return nil, err } assignList, err := l.ListAssignments(ctx) if err != nil { return nil, err } assigns := map[ItemKey]ledger.Assignment{} for _, a := range assignList { assigns[ItemKey{a.Tenant, a.Protocol, a.Router}] = a } // Orte zu Posten zusammenfassen. items := map[ItemKey]*item{} for _, s := range spans { k := ItemKey{s.Tenant, s.Protocol, s.Router} it := items[k] if it == nil { it = &item{key: k, first: s.FirstSeen, latest: s} items[k] = it } if s.FirstSeen.Before(it.first) { it.first = s.FirstSeen } if s.Ended.IsZero() { it.open = true } else if s.Ended.After(it.ended) { it.ended = s.Ended } if !s.FirstSeen.Before(it.latest.FirstSeen) { it.latest = s } } for _, it := range items { if it.open { it.ended = time.Time{} } } var lines []BillLine activeCustomers := map[string]bool{} for _, it := range items { a, assigned := assigns[it.key] startMonth := time.Date(it.first.In(loc).Year(), it.first.In(loc).Month(), 1, 0, 0, 0, 0, loc) if assigned && a.Start != "" { if t, err := time.ParseInLocation("2006-01", a.Start, loc); err == nil { startMonth = t } } if start.Before(startMonth) || (!it.ended.IsZero() && it.ended.Before(start)) { continue // nicht in diesem Monat } custID := resolver.Resolve(it.latest.Payee, it.key.Tenant) cust, known := byID[custID] ln := BillLine{ Month: month, Customer: custID, CustomerName: cust.Name, Group: cust.Group, Cycle: cust.Cycle, Item: it.key.Tenant + "/" + it.key.Protocol + "/" + it.key.Router, Hosts: strings.Join(it.latest.Hosts, " "), Tenant: it.key.Tenant, From: it.first.In(loc).Format(time.DateOnly), CustomerNote: cust.Note, } var notes []string if !known { ln.Customer = "" notes = append(notes, "Kunde unbekannt – sultan accountant customer map") } else { activeCustomers[custID] = true } // Klasse und Produkt: Zuordnung > Produkt (Zuordnung, sonst Label) > Pauschale > nicht zugeordnet. ln.Product = it.latest.Product if assigned && a.Product != "" { ln.Product = a.Product } var price *int64 if ln.Product != "" { if v, ok := cat.At(ln.Product, custID, month); ok { ln.Class, price = v.Class, v.Price } else { notes = append(notes, "Produkt "+ln.Product+" nicht im Katalog") } } if assigned && a.Class != "" { ln.Class = a.Class } if ln.Class == "" && ln.Product == "" && known && cust.FlatRate != nil { ln.Class = ledger.ClassIncluded } switch ln.Class { case ledger.ClassBillable: if price != nil { ln.Amount = ptr(*price) } else { notes = append(notes, "kein Preis hinterlegt") } case ledger.ClassIncluded: ln.Amount = ptr(0) case ledger.ClassFree, ledger.ClassInternal: ln.Amount, ln.Shadow = ptr(0), price case "": if !slices.ContainsFunc(notes, func(n string) bool { return strings.Contains(n, "nicht im Katalog") }) { notes = append(notes, "nicht zugeordnet – Label sultan.accountant.billing oder sultan accountant assign") } } if !it.ended.IsZero() && it.ended.Before(end) { ln.To = it.ended.In(loc).Format(time.DateOnly) used := int64(it.ended.In(loc).Day()) n := fmt.Sprintf("beendet am %s (Ultimo abgerechnet)", it.ended.In(loc).Format("02.01.")) if ln.Amount != nil && *ln.Amount > 0 { n += fmt.Sprintf("; tagesgenau %d/%d Tage = %s", used, daysInMonth, FormatMoney((*ln.Amount*used+daysInMonth/2)/daysInMonth)) } notes = append(notes, n) } factor, cycleNote := cycleFactor(cust.Cycle) if known && cycleNote != "" { notes = append(notes, cycleNote) } if ln.Amount != nil && factor > 0 { ln.PeriodAmount = ptr(*ln.Amount * factor) } ln.Note = strings.Join(notes, "; ") lines = append(lines, ln) } // Pauschale je Kunde. for _, c := range customers { if c.FlatRate == nil { continue } ln := BillLine{Month: month, Customer: c.ID, CustomerName: c.Name, Group: c.Group, Cycle: c.Cycle, Item: ItemFlatRate, Amount: ptr(*c.FlatRate), CustomerNote: c.Note} var notes []string factor, cycleNote := cycleFactor(c.Cycle) if factor > 0 { ln.PeriodAmount = ptr(*c.FlatRate * factor) } if cycleNote != "" { notes = append(notes, cycleNote) } if !activeCustomers[c.ID] { notes = append(notes, "keine aktiven URLs in diesem Monat") } ln.Note = strings.Join(notes, "; ") lines = append(lines, ln) } slices.SortFunc(lines, func(a, b BillLine) int { if (a.Customer == "") != (b.Customer == "") { if a.Customer == "" { return 1 } return -1 } if c := strings.Compare(a.Customer, b.Customer); c != 0 { return c } if (a.Item == ItemFlatRate) != (b.Item == ItemFlatRate) { if a.Item == ItemFlatRate { return -1 } return 1 } return cmp.Or(strings.Compare(a.Hosts, b.Hosts), strings.Compare(a.Item, b.Item)) }) return lines, nil } var cycleNames = map[string]string{ ledger.CycleMonthly: "monatlich", ledger.CycleQuarterly: "quartalsweise", ledger.CycleYearly: "jährlich", ledger.CycleUnclassified: "nicht eingeordnet", } // WriteBillCSV schreibt die Abrechnung als CSV (UTF-8 mit BOM, Semikolon, // Dezimalkomma) – so liest sie Excel bzw. LibreOffice ohne Importdialog. func WriteBillCSV(w io.Writer, lines []BillLine) error { if _, err := io.WriteString(w, "\uFEFF"); err != nil { return err } cw := csv.NewWriter(w) cw.Comma = ';' money := func(p *int64) string { if p == nil { return "" } return FormatMoney(*p) } cw.Write([]string{"Monat", "Kunde", "Kundenname", "Gruppe", "Zahlweise", "Posten", "Hosts", "Mandant", "Klasse", "Produkt", "Von", "Bis", "Monatsbetrag", "Periodenbetrag", "Schattenpreis", "Hinweis", "Notiz"}) for _, l := range lines { cycle := cycleNames[l.Cycle] if l.Customer == "" { cycle = "" } cw.Write([]string{l.Month, l.Customer, l.CustomerName, l.Group, cycle, l.Item, l.Hosts, l.Tenant, l.Class, l.Product, l.From, l.To, money(l.Amount), money(l.PeriodAmount), money(l.Shadow), l.Note, l.CustomerNote}) } cw.Flush() return cw.Error() }