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)") } }