Accountant: Inventar, where, Checkmk, Abrechnung

This commit is contained in:
br committed 2026-10-18 17:08:58 +02:00
1 parent 9f16148cde
commit dd93277750
59 files changed
+7522 -113

No files matched your search

+203
View File
@@ -0,0 +1,203 @@
package accountant
import (
"bytes"
"encoding/csv"
"path/filepath"
"strings"
"testing"
"time"
"sultan/pkg/ledger"
)
func day(y int, m time.Month, d int) time.Time { return time.Date(y, m, d, 12, 0, 0, 0, time.UTC) }
func cents(v int64) *int64 { return &v }
func span(payee, tenant, node, router, product string, first, ended time.Time, hosts ...string) ledger.RouteSpan {
return ledger.RouteSpan{
RouteRecord: ledger.RouteRecord{Tenant: tenant, Node: node, Protocol: "http", Router: router, Hosts: hosts, Product: product},
Payee: payee, FirstSeen: first, LastSeen: first, Ended: ended,
}
}
func billingFixture(t *testing.T) (*ledger.Ledger, []ledger.RouteSpan) {
t.Helper()
l, err := ledger.Open(ctx, filepath.Join(t.TempDir(), "accountant.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
flat := int64(8900)
for _, c := range []ledger.Customer{
{ID: "kunde", Name: "Kunde A", Group: "extern", Cycle: ledger.CycleQuarterly, FlatRate: &flat, Note: "Kumpel"},
{ID: "jahr", Name: "Jahreszahler", Cycle: ledger.CycleYearly},
{ID: "familie", Name: "Familie", Group: "familie", Cycle: ledger.CycleMonthly},
{ID: "offen", Name: "Noch offen"},
} {
if err := l.AddCustomer(ctx, c); err != nil {
t.Fatal(err)
}
}
l.MapTenant(ctx, "", "kunde-a", "kunde") // zweiter Mandant desselben Kunden
l.AddProduct(ctx, ledger.Product{ID: "olat", Name: "OLAT", Class: ledger.ClassBillable, Price: cents(4900)})
l.AddProduct(ctx, ledger.Product{ID: "pingvin", Name: "Pingvin", Class: ledger.ClassFree, Price: cents(500)})
l.SetProductVersion(ctx, "pingvin", "2027-03", ledger.ClassBillable, cents(500))
l.AddProduct(ctx, ledger.Product{ID: "hochzeit", Name: "Landingpage", Class: ledger.ClassInternal, Price: cents(1500)})
l.SetCustomerPrice(ctx, "olat", "jahr", "2026-01", 3900)
l.SetAssignment(ctx, ledger.Assignment{Tenant: "kunde", Protocol: "http", Router: "spaet", Start: "2026-12"})
l.SetAssignment(ctx, ledger.Assignment{Tenant: "kunde", Protocol: "http", Router: "wp", Class: ledger.ClassIncluded})
open := time.Time{}
spans := []ledger.RouteSpan{
// Kunde A: Website (Umzug webdocker07 → webdocker11, ein Posten), OLAT ab Mitte Oktober,
// gekündigter Backup-Dienst, später beginnender Dienst, Pingvin, unbekanntes Produkt.
span("p1", "kunde", "webdocker07", "wp", "", day(2026, 1, 5), day(2026, 9, 30), "kunde.de"),
span("p1", "kunde", "webdocker11", "wp", "", day(2026, 9, 30), open, "kunde.de", "www.kunde.de"),
span("p1", "kunde", "webdocker11", "olat", "olat", day(2026, 10, 17), open, "olat.kunde.de"),
span("p2", "kunde-a", "nb1", "backup", "olat", day(2026, 3, 1), day(2026, 10, 14), "backup.kunde.de"),
span("p1", "kunde", "webdocker11", "spaet", "olat", day(2026, 10, 2), open, "spaet.kunde.de"),
span("p1", "kunde", "webdocker11", "share", "pingvin", day(2026, 5, 1), open, "share.kunde.de"),
span("p1", "kunde", "webdocker11", "alt", "gibts-nicht", day(2026, 5, 1), open, "alt.kunde.de"),
span("p1", "kunde", "webdocker11", "weg", "olat", day(2026, 5, 1), day(2026, 9, 3), "weg.kunde.de"),
// Jahreszahler mit Kundenpreis.
span("p1", "jahr", "n2", "olat", "olat", day(2026, 2, 1), open, "olat.jahr.de"),
// Familie: intern mit Schattenpreis.
span("p1", "familie", "n3", "hochzeit", "hochzeit", day(2026, 6, 1), open, "hochzeit.example.de"),
// Ohne Pauschale, ohne Label, Zahlweise offen.
span("p1", "offen", "n4", "web", "", day(2026, 6, 1), open, "offen.de"),
// Mandant ohne Kunden.
span("p1", "fremd", "n5", "web", "", day(2026, 6, 1), open, "fremd.de"),
}
return l, spans
}
func lineFor(t *testing.T, lines []BillLine, item string) BillLine {
t.Helper()
for _, l := range lines {
if l.Item == item {
return l
}
}
t.Fatalf("Posten %q fehlt", item)
return BillLine{}
}
func amount(p *int64) string {
if p == nil {
return ""
}
return FormatMoney(*p)
}
func TestBill_October(t *testing.T) {
l, spans := billingFixture(t)
lines, err := billFrom(ctx, l, "2026-10", spans, time.UTC)
if err != nil {
t.Fatal(err)
}
tests := []struct {
item, customer, class, product, amount, period, shadow, note string
}{
{ItemFlatRate, "kunde", "", "", "89,00", "267,00", "", "Quartalszahler"},
{"kunde/http/wp", "kunde", ledger.ClassIncluded, "", "0,00", "0,00", "", ""},
{"kunde/http/olat", "kunde", ledger.ClassBillable, "olat", "49,00", "147,00", "", ""}, // Mitte Oktober: voller Monat
{"kunde-a/http/backup", "kunde", ledger.ClassBillable, "olat", "49,00", "147,00", "", "tagesgenau 14/31"},
{"kunde/http/share", "kunde", ledger.ClassFree, "pingvin", "0,00", "0,00", "5,00", ""},
{"kunde/http/alt", "kunde", "", "gibts-nicht", "", "", "", "nicht im Katalog"},
{"jahr/http/olat", "jahr", ledger.ClassBillable, "olat", "39,00", "468,00", "", "Jahreszahler"},
{"familie/http/hochzeit", "familie", ledger.ClassInternal, "hochzeit", "0,00", "0,00", "15,00", ""},
{"offen/http/web", "offen", "", "", "", "", "", "nicht zugeordnet"},
{"fremd/http/web", "", "", "", "", "", "", "Kunde unbekannt"},
}
for _, tt := range tests {
ln := lineFor(t, lines, tt.item)
if ln.Customer != tt.customer || ln.Class != tt.class || ln.Product != tt.product || amount(ln.Amount) != tt.amount ||
amount(ln.PeriodAmount) != tt.period || amount(ln.Shadow) != tt.shadow || !strings.Contains(ln.Note, tt.note) {
t.Errorf("%s:\n %+v\nerwartet Kunde %q Klasse %q Produkt %q Betrag %q Periode %q Schatten %q Hinweis ~%q",
tt.item, ln, tt.customer, tt.class, tt.product, tt.amount, tt.period, tt.shadow, tt.note)
}
}
// Gegenrechnung: 49,00 × 14/31 = 22,13.
if ln := lineFor(t, lines, "kunde-a/http/backup"); !strings.Contains(ln.Note, "22,13") || ln.To != "2026-10-14" {
t.Errorf("Kündigung: %+v", ln)
}
if ln := lineFor(t, lines, "offen/http/web"); !strings.Contains(ln.Note, "Zahlweise") {
t.Errorf("Vermerk Zahlweise fehlt: %q", ln.Note)
}
if ln := lineFor(t, lines, "kunde/http/wp"); ln.Hosts != "kunde.de www.kunde.de" || ln.From != "2026-01-05" || ln.Group != "extern" || ln.CustomerNote != "Kumpel" {
t.Errorf("Website (Umzug = ein Posten): %+v", ln)
}
for _, ln := range lines {
switch ln.Item {
case "kunde/http/spaet", "kunde/http/weg":
t.Errorf("%s darf im Oktober nicht erscheinen: %+v", ln.Item, ln)
}
}
}
// Beginn per Zuordnung im Dezember; Pingvin ab März 2027 kostenpflichtig;
// beendete Posten verschwinden im Folgemonat.
func TestBill_LaterMonths(t *testing.T) {
l, spans := billingFixture(t)
dec, _ := billFrom(ctx, l, "2026-12", spans, time.UTC)
if ln := lineFor(t, dec, "kunde/http/spaet"); amount(ln.Amount) != "49,00" {
t.Errorf("Beginn Dezember: %+v", ln)
}
for _, ln := range dec {
if ln.Item == "kunde-a/http/backup" {
t.Errorf("gekündigter Posten im Dezember: %+v", ln)
}
}
mar, _ := billFrom(ctx, l, "2027-03", spans, time.UTC)
if ln := lineFor(t, mar, "kunde/http/share"); ln.Class != ledger.ClassBillable || amount(ln.Amount) != "5,00" {
t.Errorf("Pingvin ab März: %+v", ln)
}
jan, _ := billFrom(ctx, l, "2026-01", spans, time.UTC)
for _, ln := range jan {
if ln.Item == "kunde/http/olat" {
t.Errorf("Posten vor seinem Beginn: %+v", ln)
}
}
if _, err := billFrom(ctx, l, "Oktober", spans, time.UTC); err == nil {
t.Error("ungültiger Monat akzeptiert")
}
}
// CSV: Semikolon, Kopfzeile, Dezimalkomma; Pauschale je Kunde vor seinen Posten.
func TestWriteBillCSV(t *testing.T) {
l, spans := billingFixture(t)
lines, _ := billFrom(ctx, l, "2026-10", spans, time.UTC)
var buf bytes.Buffer
if err := WriteBillCSV(&buf, lines); err != nil {
t.Fatal(err)
}
r := csv.NewReader(strings.NewReader(strings.TrimPrefix(buf.String(), "\uFEFF")))
r.Comma = ';'
rows, err := r.ReadAll()
if err != nil {
t.Fatalf("CSV unlesbar: %v\n%s", err, buf.String())
}
head := strings.Join(rows[0], ";")
for _, col := range []string{"Monat", "Kunde", "Gruppe", "Zahlweise", "Posten", "Hosts", "Klasse", "Monatsbetrag", "Periodenbetrag", "Schattenpreis", "Hinweis", "Notiz"} {
if !strings.Contains(head, col) {
t.Errorf("Spalte %s fehlt: %s", col, head)
}
}
if len(rows) != len(lines)+1 {
t.Errorf("%d Zeilen, erwartet %d", len(rows), len(lines)+1)
}
var kundeRows []string
for _, row := range rows[1:] {
if row[1] == "kunde" {
kundeRows = append(kundeRows, row[5])
}
}
if len(kundeRows) == 0 || kundeRows[0] != ItemFlatRate {
t.Errorf("Pauschale nicht zuerst: %v", kundeRows)
}
if !strings.HasPrefix(buf.String(), "\uFEFF") {
t.Error("ohne BOM (Excel erkennt UTF-8 sonst nicht)")
}
}