204 lines
8.3 KiB
Go
204 lines
8.3 KiB
Go
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)")
|
||
}
|
||
}
|