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

+299
View File
@@ -0,0 +1,299 @@
package ledger
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"sultan/pkg/policy"
"sultan/pkg/store"
)
// Zahlweisen eines Kunden (Spec 5.3).
const (
CycleUnclassified = "" // Feedback der Buchhaltung fehlt noch
CycleMonthly = "monthly" // monatlich
CycleQuarterly = "quarterly" // quartalsweise
CycleYearly = "yearly" // jährlich
)
// Customer ist ein Kunde des Hosters; Mandanten der Payees werden ihm
// zugeordnet.
type Customer struct {
ID string
Name string
Group string // Kostenverursacher-Gruppe zum Clustern (extern, intern, familie …)
Note string // persönliche Nebenabreden
Cycle string // Cycle*
// FlatRate: Pauschale je Monat in Cent; nil = keine Pauschale.
FlatRate *int64
// Auto: automatisch aus einer Mandanten-ID angelegt und noch nie von Hand
// bearbeitet (Name folgt dann dem Mandanten).
Auto bool
}
// TenantRef ist ein Mandant aus einem Bericht.
type TenantRef struct {
ID string
Name string
}
// Mapping ordnet einen Mandanten (auf einem Payee oder, Payee leer, auf
// allen) einem Kunden zu.
type Mapping struct {
Payee string
Tenant string
Customer string
}
func validCycle(c string) bool {
switch c {
case CycleUnclassified, CycleMonthly, CycleQuarterly, CycleYearly:
return true
}
return false
}
func (c *Customer) validate() error {
c.Name, c.Group, c.Note = strings.TrimSpace(c.Name), strings.TrimSpace(c.Group), strings.TrimSpace(c.Note)
switch {
case !policy.ValidID(c.ID):
return invalid("Kunden-ID %q ungültig (a–z, 0–9, - und _, kein \"__\")", c.ID)
case c.Name == "":
return invalid("Name fehlt")
case !validCycle(c.Cycle):
return invalid("Zahlweise %q unbekannt", c.Cycle)
case c.FlatRate != nil && *c.FlatRate < 0:
return invalid("Pauschale negativ")
}
return nil
}
const customerColumns = `id, name, grp, note, cycle, flat_rate, auto`
func scanCustomer(row interface{ Scan(...any) error }) (Customer, error) {
var c Customer
var flat sql.NullInt64
err := row.Scan(&c.ID, &c.Name, &c.Group, &c.Note, &c.Cycle, &flat, &c.Auto)
if flat.Valid {
c.FlatRate = &flat.Int64
}
return c, err
}
func flatArg(f *int64) any {
if f == nil {
return nil
}
return *f
}
// AddCustomer legt einen Kunden von Hand an.
func (l *Ledger) AddCustomer(ctx context.Context, c Customer) error {
if err := c.validate(); err != nil {
return err
}
tx, err := l.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM customers WHERE id = ?`, c.ID); err != nil {
return err
} else if exists {
return fmt.Errorf("Kunde %s: %w", c.ID, store.ErrExists)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO customers (`+customerColumns+`) VALUES (?, ?, ?, ?, ?, ?, 0)`,
c.ID, c.Name, c.Group, c.Note, c.Cycle, flatArg(c.FlatRate)); err != nil {
return err
}
return tx.Commit()
}
// UpdateCustomer übernimmt alle Felder; danach gilt der Kunde als von Hand
// gepflegt (Auto = false).
func (l *Ledger) UpdateCustomer(ctx context.Context, c Customer) error {
if err := c.validate(); err != nil {
return err
}
res, err := l.db.ExecContext(ctx, `UPDATE customers SET name = ?, grp = ?, note = ?, cycle = ?, flat_rate = ?, auto = 0
WHERE id = ?`, c.Name, c.Group, c.Note, c.Cycle, flatArg(c.FlatRate), c.ID)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n == 0 {
return fmt.Errorf("Kunde %s: %w", c.ID, store.ErrNotFound)
}
return nil
}
// GetCustomer liefert einen Kunden.
func (l *Ledger) GetCustomer(ctx context.Context, id string) (Customer, error) {
c, err := scanCustomer(l.db.QueryRowContext(ctx, `SELECT `+customerColumns+` FROM customers WHERE id = ?`, id))
if errors.Is(err, sql.ErrNoRows) {
return Customer{}, fmt.Errorf("Kunde %s: %w", id, store.ErrNotFound)
}
return c, err
}
// ListCustomers liefert alle Kunden, sortiert nach ID.
func (l *Ledger) ListCustomers(ctx context.Context) ([]Customer, error) {
rows, err := l.db.QueryContext(ctx, `SELECT `+customerColumns+` FROM customers ORDER BY id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Customer
for rows.Next() {
c, err := scanCustomer(rows)
if err != nil {
return nil, err
}
out = append(out, c)
}
return out, rows.Err()
}
// DeleteCustomer löscht einen Kunden samt seinen Zuordnungen.
func (l *Ledger) DeleteCustomer(ctx context.Context, id string) error {
res, err := l.db.ExecContext(ctx, `DELETE FROM customers WHERE id = ?`, id)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n == 0 {
return fmt.Errorf("Kunde %s: %w", id, store.ErrNotFound)
}
return nil
}
// EnsureCustomers legt für Mandanten eines Berichts, die keinem Kunden
// zugeordnet sind, automatisch einen Kunden mit der Mandanten-ID an.
// Automatisch angelegte Kunden übernehmen Namensänderungen des Mandanten;
// von Hand gepflegte bleiben unberührt. Ungültige IDs werden übersprungen.
func (l *Ledger) EnsureCustomers(ctx context.Context, payeeID string, tenants []TenantRef) error {
tx, err := l.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
for _, t := range tenants {
if !policy.ValidID(t.ID) {
continue
}
mapped, err := rowExists(ctx, tx, `SELECT 1 FROM customer_map WHERE tenant = ? AND (payee_id = ? OR payee_id = '')`, t.ID, payeeID)
if err != nil {
return err
}
if mapped {
continue
}
name := strings.TrimSpace(t.Name)
if name == "" {
name = t.ID
}
if _, err := tx.ExecContext(ctx, `INSERT INTO customers (id, name, grp, note, cycle, flat_rate, auto)
VALUES (?, ?, '', '', '', NULL, 1)
ON CONFLICT(id) DO UPDATE SET name = excluded.name WHERE customers.auto = 1`, t.ID, name); err != nil {
return err
}
}
return tx.Commit()
}
// MapTenant ordnet den Mandanten tenant (auf payee; "" = auf allen Payees)
// dem Kunden customer zu; eine bestehende Zuordnung wird ersetzt.
func (l *Ledger) MapTenant(ctx context.Context, payee, tenant, customer string) error {
if !policy.ValidID(tenant) {
return invalid("Mandanten-ID %q ungültig", tenant)
}
if _, err := l.GetCustomer(ctx, customer); err != nil {
return err
}
_, err := l.db.ExecContext(ctx, `INSERT INTO customer_map (payee_id, tenant, customer_id) VALUES (?, ?, ?)
ON CONFLICT(payee_id, tenant) DO UPDATE SET customer_id = excluded.customer_id`, payee, tenant, customer)
return err
}
// UnmapTenant entfernt eine Zuordnung.
func (l *Ledger) UnmapTenant(ctx context.Context, payee, tenant string) error {
res, err := l.db.ExecContext(ctx, `DELETE FROM customer_map WHERE payee_id = ? AND tenant = ?`, payee, tenant)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n == 0 {
return fmt.Errorf("Zuordnung %s/%s: %w", payee, tenant, store.ErrNotFound)
}
return nil
}
// ListMappings liefert alle Zuordnungen, sortiert nach Mandant und Payee.
func (l *Ledger) ListMappings(ctx context.Context) ([]Mapping, error) {
rows, err := l.db.QueryContext(ctx, `SELECT payee_id, tenant, customer_id FROM customer_map ORDER BY tenant, payee_id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Mapping
for rows.Next() {
var m Mapping
if err := rows.Scan(&m.Payee, &m.Tenant, &m.Customer); err != nil {
return nil, err
}
out = append(out, m)
}
return out, rows.Err()
}
// CustomerResolver löst (Payee, Mandant) zum Kunden auf: Zuordnung für den
// Payee > Zuordnung für alle Payees > Kunde mit der Mandanten-ID > "".
type CustomerResolver struct {
byPayee map[[2]string]string
byTenant map[string]string
customers map[string]bool
}
// CustomerResolver liest Kunden und Zuordnungen für viele Auflösungen.
func (l *Ledger) CustomerResolver(ctx context.Context) (*CustomerResolver, error) {
r := &CustomerResolver{byPayee: map[[2]string]string{}, byTenant: map[string]string{}, customers: map[string]bool{}}
maps, err := l.ListMappings(ctx)
if err != nil {
return nil, err
}
for _, m := range maps {
if m.Payee == "" {
r.byTenant[m.Tenant] = m.Customer
} else {
r.byPayee[[2]string{m.Payee, m.Tenant}] = m.Customer
}
}
cs, err := l.ListCustomers(ctx)
if err != nil {
return nil, err
}
for _, c := range cs {
r.customers[c.ID] = true
}
return r, nil
}
// Resolve liefert die Kunden-ID ("" = keinem Kunden zugeordnet).
func (r *CustomerResolver) Resolve(payee, tenant string) string {
if c, ok := r.byPayee[[2]string{payee, tenant}]; ok {
return c
}
if c, ok := r.byTenant[tenant]; ok {
return c
}
if r.customers[tenant] {
return tenant
}
return ""
}
+118
View File
@@ -0,0 +1,118 @@
package ledger
import (
"errors"
"testing"
"sultan/pkg/store"
)
// Kunden entstehen automatisch aus Mandanten-IDs; vorhandene bleiben unberührt.
func TestEnsureCustomers(t *testing.T) {
l := open(t)
if err := l.EnsureCustomers(ctx, "webproxy11", []TenantRef{{ID: "kunde", Name: "Kunde A"}, {ID: "andere", Name: "Andere"}}); err != nil {
t.Fatal(err)
}
c, err := l.GetCustomer(ctx, "kunde")
if err != nil || c.Name != "Kunde A" || !c.Auto || c.Cycle != CycleUnclassified || c.FlatRate != nil {
t.Errorf("automatisch angelegt: %+v, %v", c, err)
}
// Von Hand geändert: ein späterer Bericht überschreibt nichts.
c.Name, c.Group, c.Note, c.Cycle = "Kunde A GmbH", "extern", "Kumpel, zahlt in Bier", CycleQuarterly
cents := int64(8900)
c.FlatRate = &cents
if err := l.UpdateCustomer(ctx, c); err != nil {
t.Fatal(err)
}
l.EnsureCustomers(ctx, "webproxy11", []TenantRef{{ID: "kunde", Name: "Neuer Name"}})
got, _ := l.GetCustomer(ctx, "kunde")
if got.Name != "Kunde A GmbH" || got.Auto || got.Group != "extern" || got.Cycle != CycleQuarterly || *got.FlatRate != 8900 {
t.Errorf("nach erneutem Bericht: %+v", got)
}
// Ungültige Mandanten-IDs (sollte es nicht geben) werden übersprungen.
if err := l.EnsureCustomers(ctx, "webproxy11", []TenantRef{{ID: "Kein Gültiger"}}); err != nil {
t.Errorf("ungültige ID: %v", err)
}
}
// Zuordnung: Payee + Mandant > Mandant auf allen Payees > gleiche ID. Ein
// zugeordneter Mandant erzeugt keinen eigenen Kunden mehr.
func TestResolveCustomer(t *testing.T) {
l := open(t)
l.AddCustomer(ctx, Customer{ID: "k-1001", Name: "Kunde A"})
l.AddCustomer(ctx, Customer{ID: "familie", Name: "Familie", Group: "familie"})
if err := l.MapTenant(ctx, "", "kunde", "k-1001"); err != nil {
t.Fatal(err)
}
if err := l.MapTenant(ctx, "webproxy12", "kunde-a", "k-1001"); err != nil { // Tippfehler auf Nebencluster
t.Fatal(err)
}
l.EnsureCustomers(ctx, "webproxy11", []TenantRef{{ID: "kunde"}, {ID: "neffe"}})
l.EnsureCustomers(ctx, "webproxy12", []TenantRef{{ID: "kunde-a"}})
tests := []struct{ payee, tenant, want string }{
{"webproxy11", "kunde", "k-1001"},
{"webproxy12", "kunde", "k-1001"},
{"webproxy12", "kunde-a", "k-1001"},
{"webproxy11", "neffe", "neffe"}, // automatisch
{"webproxy11", "unbekannt", ""},
}
r, err := l.CustomerResolver(ctx)
if err != nil {
t.Fatal(err)
}
for _, tt := range tests {
if got := r.Resolve(tt.payee, tt.tenant); got != tt.want {
t.Errorf("Resolve(%s, %s) = %q, erwartet %q", tt.payee, tt.tenant, got, tt.want)
}
}
if _, err := l.GetCustomer(ctx, "kunde"); !errors.Is(err, store.ErrNotFound) {
t.Errorf("zugeordneter Mandant hat eigenen Kunden erzeugt: %v", err)
}
if _, err := l.GetCustomer(ctx, "kunde-a"); !errors.Is(err, store.ErrNotFound) {
t.Errorf("Tippfehler-Mandant hat eigenen Kunden erzeugt: %v", err)
}
maps, _ := l.ListMappings(ctx)
if len(maps) != 2 {
t.Errorf("Zuordnungen: %+v", maps)
}
if err := l.UnmapTenant(ctx, "webproxy12", "kunde-a"); err != nil {
t.Fatal(err)
}
if err := l.UnmapTenant(ctx, "webproxy12", "kunde-a"); !errors.Is(err, store.ErrNotFound) {
t.Errorf("zweites Entfernen: %v", err)
}
if err := l.MapTenant(ctx, "", "x", "gibts-nicht"); !errors.Is(err, store.ErrNotFound) {
t.Errorf("Zuordnung zu unbekanntem Kunden: %v", err)
}
}
func TestCustomers_CRUD(t *testing.T) {
l := open(t)
if err := l.AddCustomer(ctx, Customer{ID: "familie", Name: "Familie", Group: "familie", Cycle: CycleYearly}); err != nil {
t.Fatal(err)
}
if err := l.AddCustomer(ctx, Customer{ID: "familie", Name: "x"}); !errors.Is(err, store.ErrExists) {
t.Errorf("doppelt: %v", err)
}
for _, bad := range []Customer{{ID: "Ungültig", Name: "x"}, {ID: "ok", Name: ""}, {ID: "ok2", Name: "x", Cycle: "wöchentlich"}} {
if err := l.AddCustomer(ctx, bad); !errors.Is(err, store.ErrInvalid) {
t.Errorf("%+v: %v", bad, err)
}
}
l.AddCustomer(ctx, Customer{ID: "k-1001", Name: "Kunde A", Group: "extern"})
list, _ := l.ListCustomers(ctx)
if len(list) != 2 || list[0].ID != "familie" {
t.Errorf("Liste: %+v", list)
}
if err := l.UpdateCustomer(ctx, Customer{ID: "gibts-nicht", Name: "x"}); !errors.Is(err, store.ErrNotFound) {
t.Errorf("Update unbekannt: %v", err)
}
l.MapTenant(ctx, "", "kunde", "k-1001")
if err := l.DeleteCustomer(ctx, "k-1001"); err != nil {
t.Fatal(err)
}
if maps, _ := l.ListMappings(ctx); len(maps) != 0 {
t.Errorf("Zuordnungen nach Löschen: %+v", maps)
}
}
+485
View File
@@ -0,0 +1,485 @@
// Package ledger ist die Datenbank des Accountants (accountant.db, Spec
// 5.3): registrierte Payees (meldende Sultan-Server), ihre Einladungen und
// Einstellungen. der jeweils letzte Bericht je Payee und die Historie (Zeiträume je Ort).
//
// Bewusst getrennt von sultan.db: andere Lebensdauer (Historie über Jahre)
// und anderes Vertrauen (Berichte sind Eingaben fremder Server). Migrationen
// und Zeitformat kommen aus pkg/store; Fehler sind die von pkg/store
// (ErrNotFound, ErrExists, ErrInvalid, ErrTokenUsed, ErrTokenExpired).
package ledger
import (
"context"
"crypto/sha256"
"database/sql"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"slices"
"strings"
"time"
"sultan/pkg/policy"
"sultan/pkg/store"
)
// migrations: nur anhängen, nie ändern (siehe store.OpenDB).
var migrations = []string{
`CREATE TABLE payees (
id TEXT PRIMARY KEY,
hostname TEXT NOT NULL,
public_key TEXT NOT NULL UNIQUE,
ip_address TEXT NOT NULL,
enrolled_at DATETIME NOT NULL
);
CREATE TABLE invites (
token_hash TEXT PRIMARY KEY,
payee_id TEXT NOT NULL,
expires_at DATETIME NOT NULL,
used INTEGER NOT NULL DEFAULT 0,
used_at DATETIME
);
CREATE TABLE settings (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);`,
// v2: letzter Bericht je Payee.
`CREATE TABLE reports (
payee_id TEXT PRIMARY KEY REFERENCES payees(id) ON DELETE CASCADE,
received_at DATETIME NOT NULL,
body TEXT NOT NULL
);`,
// v3: Historie – Zeiträume je Ort (Payee, Mandant, Knoten, Protokoll,
// Router). Ohne Fremdschlüssel: bleibt, wenn ein Payee gelöscht wird.
`CREATE TABLE route_spans (
id INTEGER PRIMARY KEY,
payee_id TEXT NOT NULL,
tenant TEXT NOT NULL,
node TEXT NOT NULL,
protocol TEXT NOT NULL,
router TEXT NOT NULL,
hosts TEXT NOT NULL,
backend TEXT NOT NULL,
backend_fqdn TEXT NOT NULL,
workload TEXT NOT NULL,
compose_dir TEXT NOT NULL,
product TEXT NOT NULL,
first_seen DATETIME NOT NULL,
last_seen DATETIME NOT NULL,
ended_at DATETIME
);
CREATE INDEX route_spans_payee_open ON route_spans(payee_id, ended_at);
CREATE INDEX route_spans_tenant ON route_spans(tenant);`,
// v4: Kunden und die Zuordnung von Mandanten (payee_id '' = alle Payees).
`CREATE TABLE customers (
id TEXT PRIMARY KEY,
name TEXT NOT NULL,
grp TEXT NOT NULL DEFAULT '',
note TEXT NOT NULL DEFAULT '',
cycle TEXT NOT NULL DEFAULT '',
flat_rate INTEGER,
auto INTEGER NOT NULL DEFAULT 0
);
CREATE TABLE customer_map (
payee_id TEXT NOT NULL DEFAULT '',
tenant TEXT NOT NULL,
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
PRIMARY KEY (payee_id, tenant)
);`,
// v5: Produktkatalog (Klasse/Preis je Monat), Kundenpreise, Zuordnung je
// Posten (Mandant, Protokoll, Router).
`CREATE TABLE products (
id TEXT PRIMARY KEY,
name TEXT NOT NULL
);
CREATE TABLE product_versions (
product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
valid_from TEXT NOT NULL,
class TEXT NOT NULL,
price INTEGER,
PRIMARY KEY (product_id, valid_from)
);
CREATE TABLE customer_prices (
product_id TEXT NOT NULL REFERENCES products(id) ON DELETE CASCADE,
customer_id TEXT NOT NULL REFERENCES customers(id) ON DELETE CASCADE,
valid_from TEXT NOT NULL,
price INTEGER NOT NULL,
PRIMARY KEY (product_id, customer_id, valid_from)
);
CREATE TABLE assignments (
tenant TEXT NOT NULL,
protocol TEXT NOT NULL,
router TEXT NOT NULL,
product_id TEXT NOT NULL DEFAULT '',
class TEXT NOT NULL DEFAULT '',
start TEXT NOT NULL DEFAULT '',
PRIMARY KEY (tenant, protocol, router)
);`,
}
// Ledger ist der Zugriff auf accountant.db; parallel nutzbar.
type Ledger struct {
db *sql.DB
now func() time.Time
}
// Payee ist ein registrierter, meldender Sultan-Server.
type Payee struct {
ID string
Hostname string
PublicKey string // auth.EncodePublicKey
IPAddress string // beim Enrollment
EnrolledAt time.Time
LastReport time.Time // Nullzeit = noch kein Bericht
}
// Open öffnet (bzw. erstellt) die Datenbank unter path.
func Open(ctx context.Context, path string) (*Ledger, error) {
db, err := store.OpenDB(ctx, path, migrations)
if err != nil {
return nil, err
}
return &Ledger{db: db, now: time.Now}, nil
}
// Close schließt die Datenbank.
func (l *Ledger) Close() error { return l.db.Close() }
// SetClock ersetzt die Uhr (Tests mit festen Zeitpunkten); nicht parallel
// zu anderen Aufrufen verwenden.
func (l *Ledger) SetClock(now func() time.Time) { l.now = now }
func hashToken(token string) string {
sum := sha256.Sum256([]byte(token))
return hex.EncodeToString(sum[:])
}
func invalid(format string, args ...any) error {
return fmt.Errorf("%w: %s", store.ErrInvalid, fmt.Sprintf(format, args...))
}
// AddInvite hinterlegt eine einmalige Einladung für den Payee payeeID
// (gespeichert wird nur der Hash von token). Ein bereits registrierter Payee
// muss erst gelöscht werden (ErrExists).
func (l *Ledger) AddInvite(ctx context.Context, token, payeeID string, expiresAt time.Time) error {
switch {
case token == "":
return invalid("Token fehlt")
case !policy.ValidID(payeeID):
return invalid("Payee-ID %q ungültig (a–z, 0–9, - und _, kein \"__\")", payeeID)
case !expiresAt.After(l.now()):
return invalid("Ablaufzeitpunkt %v liegt nicht in der Zukunft", expiresAt)
}
tx, err := l.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM payees WHERE id = ?`, payeeID); err != nil {
return err
} else if exists {
return fmt.Errorf("Payee %s: %w (erst löschen)", payeeID, store.ErrExists)
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO invites (token_hash, payee_id, expires_at, used) VALUES (?, ?, ?, 0)`,
hashToken(token), payeeID, store.FormatTime(expiresAt)); err != nil {
return err
}
return tx.Commit()
}
// Enroll löst token ein und legt den Payee an; seine ID bestimmt die
// Einladung (p.ID wird ersetzt), EnrolledAt setzt der Ledger. Schlägt das
// Anlegen fehl, bleibt die Einladung unverbraucht.
func (l *Ledger) Enroll(ctx context.Context, token string, p Payee) (Payee, error) {
if strings.TrimSpace(p.Hostname) == "" || p.PublicKey == "" {
return Payee{}, invalid("Hostname und Schlüssel sind Pflicht")
}
tx, err := l.db.BeginTx(ctx, nil)
if err != nil {
return Payee{}, err
}
defer tx.Rollback()
hash := hashToken(token)
var payeeID string
var expiresAt time.Time
var used bool
err = tx.QueryRowContext(ctx, `SELECT payee_id, expires_at, used FROM invites WHERE token_hash = ?`, hash).
Scan(&payeeID, store.ScanTime(&expiresAt), &used)
switch {
case errors.Is(err, sql.ErrNoRows):
return Payee{}, fmt.Errorf("Einladung: %w", store.ErrNotFound)
case err != nil:
return Payee{}, err
case used:
return Payee{}, store.ErrTokenUsed
case !l.now().Before(expiresAt):
return Payee{}, store.ErrTokenExpired
}
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM payees WHERE id = ? OR public_key = ?`, payeeID, p.PublicKey); err != nil {
return Payee{}, err
} else if exists {
return Payee{}, fmt.Errorf("Payee %s oder Schlüssel: %w", payeeID, store.ErrExists)
}
now := l.now().Round(0).UTC()
p.ID, p.EnrolledAt = payeeID, now
if _, err := tx.ExecContext(ctx, `UPDATE invites SET used = 1, used_at = ? WHERE token_hash = ?`,
store.FormatTime(now), hash); err != nil {
return Payee{}, err
}
if _, err := tx.ExecContext(ctx,
`INSERT INTO payees (id, hostname, public_key, ip_address, enrolled_at) VALUES (?, ?, ?, ?, ?)`,
p.ID, p.Hostname, p.PublicKey, p.IPAddress, store.FormatTime(now)); err != nil {
return Payee{}, err
}
if err := tx.Commit(); err != nil {
return Payee{}, err
}
return p, nil
}
const payeeSelect = `SELECT p.id, p.hostname, p.public_key, p.ip_address, p.enrolled_at, r.received_at
FROM payees p LEFT JOIN reports r ON r.payee_id = p.id`
func scanPayee(row interface{ Scan(...any) error }) (Payee, error) {
var p Payee
err := row.Scan(&p.ID, &p.Hostname, &p.PublicKey, &p.IPAddress, store.ScanTime(&p.EnrolledAt), store.ScanTime(&p.LastReport))
return p, err
}
// ListPayees liefert alle Payees, sortiert nach ID.
func (l *Ledger) ListPayees(ctx context.Context) ([]Payee, error) {
rows, err := l.db.QueryContext(ctx, payeeSelect+` ORDER BY p.id`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Payee
for rows.Next() {
p, err := scanPayee(rows)
if err != nil {
return nil, err
}
out = append(out, p)
}
return out, rows.Err()
}
// GetPayeeByPublicKey sucht den Payee zu einem Schlüssel (Prüfung signierter
// Berichte).
func (l *Ledger) GetPayeeByPublicKey(ctx context.Context, publicKey string) (Payee, error) {
p, err := scanPayee(l.db.QueryRowContext(ctx, payeeSelect+` WHERE p.public_key = ?`, publicKey))
if errors.Is(err, sql.ErrNoRows) {
return Payee{}, fmt.Errorf("Payee: %w", store.ErrNotFound)
}
return p, err
}
// DeletePayee entfernt einen Payee (z. B. vor einer neuen Einladung nach
// Schlüsselverlust).
func (l *Ledger) DeletePayee(ctx context.Context, id string) error {
res, err := l.db.ExecContext(ctx, `DELETE FROM payees WHERE id = ?`, id)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n == 0 {
return fmt.Errorf("Payee %s: %w", id, store.ErrNotFound)
}
return nil
}
// RouteRecord ist eine Route aus einem Bericht, wie sie in die Historie eingeht.
type RouteRecord struct {
Tenant, Node, Protocol, Router string
Hosts []string
Backend, BackendFQDN string
Workload, ComposeDir, Product string
}
func (r RouteRecord) key() string {
return r.Tenant + "\x00" + r.Node + "\x00" + r.Protocol + "\x00" + r.Router
}
// RouteSpan ist ein Zeitraum an einem Ort.
type RouteSpan struct {
RouteRecord
ID int64
Payee string
FirstSeen time.Time
LastSeen time.Time
Ended time.Time // Nullzeit = noch offen
}
// SaveReport ersetzt den letzten Bericht des Payees und führt die Historie
// fort (Zeitpunkt: jetzt): neue Orte öffnen einen Zeitraum, bestehende
// übernehmen geänderte Angaben (first_seen bleibt), fehlende enden. Ein
// Bericht ist vollständig – was fehlt, gibt es beim Payee nicht mehr.
func (l *Ledger) SaveReport(ctx context.Context, payeeID string, body []byte, routes []RouteRecord) error {
now := store.FormatTime(l.now())
tx, err := l.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM payees WHERE id = ?`, payeeID); err != nil {
return err
} else if !exists {
return fmt.Errorf("Payee %s: %w", payeeID, store.ErrNotFound)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO reports (payee_id, received_at, body) VALUES (?, ?, ?)
ON CONFLICT(payee_id) DO UPDATE SET received_at = excluded.received_at, body = excluded.body`,
payeeID, now, string(body)); err != nil {
return err
}
open := map[string]int64{}
rows, err := tx.QueryContext(ctx, `SELECT id, tenant, node, protocol, router FROM route_spans
WHERE payee_id = ? AND ended_at IS NULL`, payeeID)
if err != nil {
return err
}
for rows.Next() {
var id int64
var r RouteRecord
if err := rows.Scan(&id, &r.Tenant, &r.Node, &r.Protocol, &r.Router); err != nil {
rows.Close()
return err
}
open[r.key()] = id
}
if err := rows.Close(); err != nil {
return err
}
for _, r := range routes {
hosts := normalizeHosts(r.Hosts)
if id, ok := open[r.key()]; ok {
if _, err := tx.ExecContext(ctx, `UPDATE route_spans SET hosts = ?, backend = ?, backend_fqdn = ?,
workload = ?, compose_dir = ?, product = ?, last_seen = ? WHERE id = ?`,
hosts, r.Backend, r.BackendFQDN, r.Workload, r.ComposeDir, r.Product, now, id); err != nil {
return err
}
delete(open, r.key())
continue
}
if _, err := tx.ExecContext(ctx, `INSERT INTO route_spans (payee_id, tenant, node, protocol, router, hosts,
backend, backend_fqdn, workload, compose_dir, product, first_seen, last_seen)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
payeeID, r.Tenant, r.Node, r.Protocol, r.Router, hosts, r.Backend, r.BackendFQDN,
r.Workload, r.ComposeDir, r.Product, now, now); err != nil {
return err
}
}
for _, id := range open {
if _, err := tx.ExecContext(ctx, `UPDATE route_spans SET ended_at = ? WHERE id = ?`, now, id); err != nil {
return err
}
}
return tx.Commit()
}
// normalizeHosts speichert Hosts klein, sortiert, ohne Doppelte (JSON).
func normalizeHosts(hosts []string) string {
out := make([]string, 0, len(hosts))
for _, h := range hosts {
out = append(out, strings.ToLower(strings.TrimSpace(h)))
}
slices.Sort(out)
raw, _ := json.Marshal(slices.Compact(out))
return string(raw)
}
// HistoryFilter schränkt RouteHistory ein; leere Felder filtern nicht.
type HistoryFilter struct {
Host string // Teiltreffer, ohne Groß-/Kleinschreibung
Tenant string
Payee string
OpenOnly bool
}
// RouteHistory liefert Zeiträume, sortiert nach Beginn, Payee, Knoten, Router.
func (l *Ledger) RouteHistory(ctx context.Context, f HistoryFilter) ([]RouteSpan, error) {
q := `SELECT id, payee_id, tenant, node, protocol, router, hosts, backend, backend_fqdn, workload,
compose_dir, product, first_seen, last_seen, ended_at FROM route_spans WHERE 1=1`
var args []any
if f.Host != "" {
like := strings.NewReplacer(`\`, `\\`, `%`, `\%`, `_`, `\_`).Replace(strings.ToLower(f.Host))
q += ` AND hosts LIKE ? ESCAPE '\'`
args = append(args, "%"+like+"%")
}
if f.Tenant != "" {
q += ` AND tenant = ?`
args = append(args, f.Tenant)
}
if f.Payee != "" {
q += ` AND payee_id = ?`
args = append(args, f.Payee)
}
if f.OpenOnly {
q += ` AND ended_at IS NULL`
}
q += ` ORDER BY first_seen, payee_id, node, router, id`
rows, err := l.db.QueryContext(ctx, q, args...)
if err != nil {
return nil, err
}
defer rows.Close()
var out []RouteSpan
for rows.Next() {
var sp RouteSpan
var hosts string
if err := rows.Scan(&sp.ID, &sp.Payee, &sp.Tenant, &sp.Node, &sp.Protocol, &sp.Router, &hosts, &sp.Backend,
&sp.BackendFQDN, &sp.Workload, &sp.ComposeDir, &sp.Product, store.ScanTime(&sp.FirstSeen),
store.ScanTime(&sp.LastSeen), store.ScanTime(&sp.Ended)); err != nil {
return nil, err
}
if err := json.Unmarshal([]byte(hosts), &sp.Hosts); err != nil {
return nil, fmt.Errorf("Historie %d: Hosts unlesbar: %w", sp.ID, err)
}
out = append(out, sp)
}
return out, rows.Err()
}
// LatestReport liefert den letzten Bericht des Payees und seinen Eingang.
func (l *Ledger) LatestReport(ctx context.Context, payeeID string) ([]byte, time.Time, error) {
var body string
var at time.Time
err := l.db.QueryRowContext(ctx, `SELECT body, received_at FROM reports WHERE payee_id = ?`, payeeID).
Scan(&body, store.ScanTime(&at))
if errors.Is(err, sql.ErrNoRows) {
return nil, time.Time{}, fmt.Errorf("Bericht von %s: %w", payeeID, store.ErrNotFound)
}
return []byte(body), at, err
}
// Setting liefert eine Einstellung ("" wenn nicht gesetzt).
func (l *Ledger) Setting(ctx context.Context, key string) (string, error) {
var v string
err := l.db.QueryRowContext(ctx, `SELECT value FROM settings WHERE key = ?`, key).Scan(&v)
if errors.Is(err, sql.ErrNoRows) {
return "", nil
}
return v, err
}
// SetSetting setzt eine Einstellung.
func (l *Ledger) SetSetting(ctx context.Context, key, value string) error {
_, err := l.db.ExecContext(ctx,
`INSERT INTO settings (key, value) VALUES (?, ?) ON CONFLICT(key) DO UPDATE SET value = excluded.value`, key, value)
return err
}
func rowExists(ctx context.Context, tx *sql.Tx, query string, args ...any) (bool, error) {
var one int
err := tx.QueryRowContext(ctx, query, args...).Scan(&one)
if errors.Is(err, sql.ErrNoRows) {
return false, nil
}
return err == nil, err
}
+280
View File
@@ -0,0 +1,280 @@
package ledger
import (
"context"
"errors"
"path/filepath"
"strings"
"testing"
"time"
"sultan/pkg/store"
)
var ctx = context.Background()
func open(t *testing.T) *Ledger {
t.Helper()
l, err := Open(ctx, filepath.Join(t.TempDir(), "accountant.db"))
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { l.Close() })
return l
}
func payee(id, key string) Payee {
return Payee{ID: id, Hostname: id + ".lan", PublicKey: key, IPAddress: "10.0.0.1"}
}
// Einladung → Enrollment legt den Payee an; die Einladung bestimmt die ID.
func TestEnroll(t *testing.T) {
l := open(t)
now := time.Date(2026, 10, 16, 9, 0, 0, 0, time.UTC)
l.now = func() time.Time { return now }
if err := l.AddInvite(ctx, "sltn_enroll_a", "webproxy11", now.Add(time.Hour)); err != nil {
t.Fatal(err)
}
p := payee("ignoriert", "key-a")
got, err := l.Enroll(ctx, "sltn_enroll_a", p)
if err != nil {
t.Fatalf("Enroll: %v", err)
}
want := Payee{ID: "webproxy11", Hostname: "ignoriert.lan", PublicKey: "key-a", IPAddress: "10.0.0.1", EnrolledAt: now}
if got != want {
t.Errorf("Enroll = %+v, erwartet %+v", got, want)
}
list, err := l.ListPayees(ctx)
if err != nil || len(list) != 1 || list[0] != want {
t.Errorf("ListPayees = %+v, %v", list, err)
}
if byKey, err := l.GetPayeeByPublicKey(ctx, "key-a"); err != nil || byKey != want {
t.Errorf("GetPayeeByPublicKey = %+v, %v", byKey, err)
}
if _, err := l.GetPayeeByPublicKey(ctx, "fremd"); !errors.Is(err, store.ErrNotFound) {
t.Errorf("unbekannter Schlüssel: %v", err)
}
}
func TestEnroll_Errors(t *testing.T) {
l := open(t)
now := time.Date(2026, 10, 16, 9, 0, 0, 0, time.UTC)
l.now = func() time.Time { return now }
l.AddInvite(ctx, "tok-1", "webproxy11", now.Add(time.Hour))
l.AddInvite(ctx, "tok-2", "webproxy12", now.Add(time.Hour))
l.AddInvite(ctx, "tok-alt", "webproxy13", now.Add(time.Minute))
if _, err := l.Enroll(ctx, "tok-1", payee("x", "key-1")); err != nil {
t.Fatal(err)
}
if _, err := l.Enroll(ctx, "gibts-nicht", payee("x", "key-9")); !errors.Is(err, store.ErrNotFound) {
t.Errorf("unbekannte Einladung: %v", err)
}
if _, err := l.Enroll(ctx, "tok-1", payee("x", "key-8")); !errors.Is(err, store.ErrTokenUsed) {
t.Errorf("verbrauchte Einladung: %v", err)
}
// Schlüssel schon vergeben: Einladung bleibt unverbraucht.
if _, err := l.Enroll(ctx, "tok-2", payee("x", "key-1")); !errors.Is(err, store.ErrExists) {
t.Errorf("doppelter Schlüssel: %v", err)
}
if _, err := l.Enroll(ctx, "tok-2", payee("x", "key-2")); err != nil {
t.Errorf("Einladung nach Fehlschlag verbraucht: %v", err)
}
now = now.Add(2 * time.Minute)
if _, err := l.Enroll(ctx, "tok-alt", payee("x", "key-3")); !errors.Is(err, store.ErrTokenExpired) {
t.Errorf("abgelaufene Einladung: %v", err)
}
if _, err := l.Enroll(ctx, "tok-x", Payee{ID: "x", PublicKey: "k"}); err == nil {
t.Error("Payee ohne Hostname akzeptiert")
}
}
func TestAddInvite_Validation(t *testing.T) {
l := open(t)
future := time.Now().Add(time.Hour)
l.AddInvite(ctx, "tok-1", "webproxy11", future)
l.Enroll(ctx, "tok-1", payee("x", "key-1"))
tests := []struct {
name, token, id string
expires time.Time
want error
}{
{"ungültige ID", "t", "Web Proxy", future, store.ErrInvalid},
{"doppelter Unterstrich", "t", "a__b", future, store.ErrInvalid},
{"ohne Token", "", "webproxy12", future, store.ErrInvalid},
{"Ablauf in der Vergangenheit", "t", "webproxy12", time.Now().Add(-time.Minute), store.ErrInvalid},
{"Payee schon registriert", "t", "webproxy11", future, store.ErrExists},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if err := l.AddInvite(ctx, tt.token, tt.id, tt.expires); !errors.Is(err, tt.want) {
t.Errorf("AddInvite = %v, erwartet %v", err, tt.want)
}
})
}
}
func TestDeletePayee(t *testing.T) {
l := open(t)
l.AddInvite(ctx, "tok-1", "webproxy11", time.Now().Add(time.Hour))
l.Enroll(ctx, "tok-1", payee("x", "key-1"))
if err := l.DeletePayee(ctx, "webproxy11"); err != nil {
t.Fatal(err)
}
if list, _ := l.ListPayees(ctx); len(list) != 0 {
t.Errorf("nach Löschen: %+v", list)
}
if err := l.DeletePayee(ctx, "webproxy11"); !errors.Is(err, store.ErrNotFound) {
t.Errorf("zweites Löschen: %v", err)
}
// Neu einladen (z. B. nach Verlust von payee.key).
if err := l.AddInvite(ctx, "tok-2", "webproxy11", time.Now().Add(time.Hour)); err != nil {
t.Errorf("neue Einladung nach Löschen: %v", err)
}
}
func TestSettings(t *testing.T) {
l := open(t)
if v, err := l.Setting(ctx, "public_urls"); err != nil || v != "" {
t.Errorf("leer: %q, %v", v, err)
}
l.SetSetting(ctx, "public_urls", `["https://a:8444"]`)
l.SetSetting(ctx, "public_urls", `["https://b:8444"]`)
if v, _ := l.Setting(ctx, "public_urls"); v != `["https://b:8444"]` {
t.Errorf("Setting = %q", v)
}
}
// Der jeweils letzte Bericht je Payee; ListPayees zeigt seinen Zeitpunkt,
// Löschen des Payees entfernt ihn mit.
func TestReports(t *testing.T) {
l := open(t)
now := time.Date(2026, 10, 17, 9, 0, 0, 0, time.UTC)
l.now = func() time.Time { return now }
l.AddInvite(ctx, "tok-1", "webproxy11", now.Add(time.Hour))
l.Enroll(ctx, "tok-1", payee("x", "key-1"))
if _, _, err := l.LatestReport(ctx, "webproxy11"); !errors.Is(err, store.ErrNotFound) {
t.Errorf("ohne Bericht: %v", err)
}
if err := l.SaveReport(ctx, "webproxy11", []byte(`{"v":1}`), nil); err != nil {
t.Fatal(err)
}
now = now.Add(time.Minute)
if err := l.SaveReport(ctx, "webproxy11", []byte(`{"v":2}`), nil); err != nil {
t.Fatal(err)
}
body, at, err := l.LatestReport(ctx, "webproxy11")
if err != nil || string(body) != `{"v":2}` || !at.Equal(now) {
t.Errorf("LatestReport = %s, %v, %v", body, at, err)
}
list, _ := l.ListPayees(ctx)
if len(list) != 1 || !list[0].LastReport.Equal(now) {
t.Errorf("ListPayees = %+v", list)
}
if err := l.SaveReport(ctx, "gibts-nicht", []byte(`{}`), nil); !errors.Is(err, store.ErrNotFound) {
t.Errorf("Bericht für unbekannten Payee: %v", err)
}
l.DeletePayee(ctx, "webproxy11")
if _, _, err := l.LatestReport(ctx, "webproxy11"); !errors.Is(err, store.ErrNotFound) {
t.Errorf("Bericht nach Löschen des Payees: %v", err)
}
}
func loc(tenant, node, router string, hosts ...string) RouteRecord {
return RouteRecord{Tenant: tenant, Node: node, Protocol: "http", Router: router, Hosts: hosts,
Backend: "http://10.0.0.11:8081", BackendFQDN: node + ".lan:8081", Workload: router, ComposeDir: "/srv/" + router}
}
// Jeder Bericht führt die Historie fort: neue Orte öffnen, fehlende enden,
// bestehende behalten first_seen und übernehmen geänderte Angaben.
func TestRouteHistory(t *testing.T) {
l := open(t)
t1 := time.Date(2026, 9, 1, 10, 0, 0, 0, time.UTC)
now := t1
l.now = func() time.Time { return now }
for _, id := range []string{"webproxy11", "webproxy12"} {
l.AddInvite(ctx, "tok-"+id, id, now.Add(time.Hour))
l.Enroll(ctx, "tok-"+id, payee("x", "key-"+id))
}
if err := l.SaveReport(ctx, "webproxy11", []byte(`{}`), []RouteRecord{
loc("kunde", "webdocker07", "wp", "kunde.de"), loc("kunde", "webdocker07", "olat", "olat.kunde.de"),
}); err != nil {
t.Fatal(err)
}
l.SaveReport(ctx, "webproxy12", []byte(`{}`), []RouteRecord{loc("andere", "n9", "web", "andere.de")})
now = t1.Add(24 * time.Hour)
moved := loc("kunde", "webdocker11", "wp", "kunde.de", "www.kunde.de") // Umzug + neuer Host
olat := loc("kunde", "webdocker07", "olat", "olat.kunde.de", "lms.kunde.de")
l.SaveReport(ctx, "webproxy11", []byte(`{}`), []RouteRecord{moved, olat})
all, err := l.RouteHistory(ctx, HistoryFilter{})
if err != nil {
t.Fatal(err)
}
type key struct{ payee, node, router string }
got := map[key]RouteSpan{}
for _, s := range all {
got[key{s.Payee, s.Node, s.Router}] = s
}
if len(all) != 4 {
t.Fatalf("%d Einträge: %+v", len(all), all)
}
old := got[key{"webproxy11", "webdocker07", "wp"}]
if !old.FirstSeen.Equal(t1) || !old.Ended.Equal(now) || !old.LastSeen.Equal(t1) {
t.Errorf("alter Ort: %+v", old)
}
cur := got[key{"webproxy11", "webdocker11", "wp"}]
if !cur.FirstSeen.Equal(now) || !cur.Ended.IsZero() || strings.Join(cur.Hosts, ",") != "kunde.de,www.kunde.de" {
t.Errorf("neuer Ort: %+v", cur)
}
o := got[key{"webproxy11", "webdocker07", "olat"}]
if !o.FirstSeen.Equal(t1) || !o.LastSeen.Equal(now) || !o.Ended.IsZero() || len(o.Hosts) != 2 {
t.Errorf("geänderte Hosts behalten first_seen: %+v", o)
}
if other := got[key{"webproxy12", "n9", "web"}]; !other.Ended.IsZero() {
t.Errorf("Bericht eines Payees beendet Orte eines anderen: %+v", other)
}
// Wieder da: neuer Zeitraum, der alte bleibt beendet.
now = t1.Add(48 * time.Hour)
l.SaveReport(ctx, "webproxy11", []byte(`{}`), []RouteRecord{moved, olat, loc("kunde", "webdocker07", "wp", "kunde.de")})
spans, _ := l.RouteHistory(ctx, HistoryFilter{Host: "kunde.de"})
var open07 int
for _, s := range spans {
if s.Node == "webdocker07" && s.Router == "wp" && s.Ended.IsZero() {
open07++
}
}
if open07 != 1 {
t.Errorf("Wiederkehr: %+v", spans)
}
// Filter: Host (Teiltreffer), Mandant, Payee; nur offene.
if s, _ := l.RouteHistory(ctx, HistoryFilter{Host: "lms"}); len(s) != 1 || s[0].Router != "olat" {
t.Errorf("Host-Teiltreffer: %+v", s)
}
if s, _ := l.RouteHistory(ctx, HistoryFilter{Tenant: "andere"}); len(s) != 1 {
t.Errorf("Mandant: %+v", s)
}
if s, _ := l.RouteHistory(ctx, HistoryFilter{Payee: "webproxy12"}); len(s) != 1 {
t.Errorf("Payee: %+v", s)
}
if s, _ := l.RouteHistory(ctx, HistoryFilter{OpenOnly: true, Tenant: "kunde"}); len(s) != 3 {
t.Errorf("nur offene: %d %+v", len(s), s)
}
}
// Ein Payee wird gelöscht: Die Historie bleibt (Abrechnung, Verlauf).
func TestRouteHistory_SurvivesPayeeDelete(t *testing.T) {
l := open(t)
l.AddInvite(ctx, "tok", "webproxy11", time.Now().Add(time.Hour))
l.Enroll(ctx, "tok", payee("x", "k"))
l.SaveReport(ctx, "webproxy11", []byte(`{}`), []RouteRecord{loc("kunde", "n1", "wp", "kunde.de")})
l.DeletePayee(ctx, "webproxy11")
if s, _ := l.RouteHistory(ctx, HistoryFilter{}); len(s) != 1 {
t.Errorf("Historie nach Löschen: %+v", s)
}
}
+326
View File
@@ -0,0 +1,326 @@
package ledger
import (
"context"
"database/sql"
"fmt"
"slices"
"strings"
"time"
"sultan/pkg/policy"
"sultan/pkg/store"
)
// Klassen eines Postens (Spec 5.3).
const (
ClassIncluded = "included" // in der Pauschale enthalten
ClassBillable = "billable" // Zusatz-Service, eigene Position
ClassFree = "free" // Give-away, erfasst, derzeit kostenlos
ClassInternal = "internal" // keine Rechnung, Schattenpreis
)
// sinceAlways ist der Beginn der ersten Version eines Produkts.
const sinceAlways = "0000-00"
// ValidClass meldet, ob c eine bekannte Klasse ist.
func ValidClass(c string) bool {
switch c {
case ClassIncluded, ClassBillable, ClassFree, ClassInternal:
return true
}
return false
}
// ValidMonth meldet, ob m die Form JJJJ-MM hat.
func ValidMonth(m string) bool {
_, err := time.Parse("2006-01", m)
return err == nil && len(m) == 7
}
// Product ist ein Eintrag im Katalog; Class und Price beim Anlegen sind die
// erste Version (gilt von Anfang an).
type Product struct {
ID string
Name string
Class string
Price *int64 // Cent je Monat; nil = kein Preis hinterlegt (optional)
Versions []ProductVersion
}
// ProductVersion gilt ab From (JJJJ-MM) bis zur nächsten Version.
type ProductVersion struct {
From string
Class string
Price *int64
}
// Assignment legt für einen Posten (Mandant, Protokoll, Router) Produkt,
// Klasse und/oder Abrechnungsbeginn fest; sie hat Vorrang vor dem Label.
type Assignment struct {
Tenant, Protocol, Router string
Product string // "" = aus dem Label
Class string // "" = aus dem Produkt
Start string // JJJJ-MM; "" = Monat des ersten Auftauchens
}
func priceArg(p *int64) any {
if p == nil {
return nil
}
return *p
}
// AddProduct legt ein Produkt an; Class/Price gelten von Anfang an.
func (l *Ledger) AddProduct(ctx context.Context, p Product) error {
p.Name = strings.TrimSpace(p.Name)
switch {
case !policy.ValidID(p.ID):
return invalid("Produkt-ID %q ungültig (a–z, 0–9, - und _)", p.ID)
case p.Name == "":
return invalid("Name fehlt")
case !ValidClass(p.Class):
return invalid("Klasse %q unbekannt (included, billable, free, internal)", p.Class)
case p.Price != nil && *p.Price < 0:
return invalid("Preis negativ")
}
tx, err := l.db.BeginTx(ctx, nil)
if err != nil {
return err
}
defer tx.Rollback()
if exists, err := rowExists(ctx, tx, `SELECT 1 FROM products WHERE id = ?`, p.ID); err != nil {
return err
} else if exists {
return fmt.Errorf("Produkt %s: %w", p.ID, store.ErrExists)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO products (id, name) VALUES (?, ?)`, p.ID, p.Name); err != nil {
return err
}
if _, err := tx.ExecContext(ctx, `INSERT INTO product_versions (product_id, valid_from, class, price) VALUES (?, ?, ?, ?)`,
p.ID, sinceAlways, p.Class, priceArg(p.Price)); err != nil {
return err
}
return tx.Commit()
}
// SetProductVersion setzt Klasse und Preis eines Produkts ab dem Monat from
// (eine bestehende Version desselben Monats wird ersetzt).
func (l *Ledger) SetProductVersion(ctx context.Context, product, from, class string, price *int64) error {
switch {
case !ValidMonth(from):
return invalid("Monat %q ungültig (JJJJ-MM)", from)
case !ValidClass(class):
return invalid("Klasse %q unbekannt", class)
case price != nil && *price < 0:
return invalid("Preis negativ")
}
if err := l.requireProduct(ctx, product); err != nil {
return err
}
_, err := l.db.ExecContext(ctx, `INSERT INTO product_versions (product_id, valid_from, class, price) VALUES (?, ?, ?, ?)
ON CONFLICT(product_id, valid_from) DO UPDATE SET class = excluded.class, price = excluded.price`,
product, from, class, priceArg(price))
return err
}
// SetCustomerPrice setzt einen Kundenpreis ab dem Monat from; er hat Vorrang
// vor dem Katalogpreis.
func (l *Ledger) SetCustomerPrice(ctx context.Context, product, customer, from string, price int64) error {
switch {
case !ValidMonth(from):
return invalid("Monat %q ungültig (JJJJ-MM)", from)
case price < 0:
return invalid("Preis negativ")
}
if err := l.requireProduct(ctx, product); err != nil {
return err
}
if _, err := l.GetCustomer(ctx, customer); err != nil {
return err
}
_, err := l.db.ExecContext(ctx, `INSERT INTO customer_prices (product_id, customer_id, valid_from, price) VALUES (?, ?, ?, ?)
ON CONFLICT(product_id, customer_id, valid_from) DO UPDATE SET price = excluded.price`, product, customer, from, price)
return err
}
func (l *Ledger) requireProduct(ctx context.Context, id string) error {
var one int
err := l.db.QueryRowContext(ctx, `SELECT 1 FROM products WHERE id = ?`, id).Scan(&one)
if err == sql.ErrNoRows {
return fmt.Errorf("Produkt %s: %w", id, store.ErrNotFound)
}
return err
}
// DeleteProduct löscht ein Produkt samt Versionen und Kundenpreisen.
func (l *Ledger) DeleteProduct(ctx context.Context, id string) error {
res, err := l.db.ExecContext(ctx, `DELETE FROM products WHERE id = ?`, id)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n == 0 {
return fmt.Errorf("Produkt %s: %w", id, store.ErrNotFound)
}
return nil
}
// Catalog sind alle Produkte samt Versionen und Kundenpreisen.
type Catalog struct {
names map[string]string
versions map[string][]ProductVersion // nach From aufsteigend
customers map[[2]string][]ProductVersion
}
// Catalog liest den Katalog für viele Abfragen.
func (l *Ledger) Catalog(ctx context.Context) (*Catalog, error) {
c := &Catalog{names: map[string]string{}, versions: map[string][]ProductVersion{}, customers: map[[2]string][]ProductVersion{}}
rows, err := l.db.QueryContext(ctx, `SELECT p.id, p.name, v.valid_from, v.class, v.price FROM products p
JOIN product_versions v ON v.product_id = p.id ORDER BY p.id, v.valid_from`)
if err != nil {
return nil, err
}
for rows.Next() {
var id, name string
var v ProductVersion
var price sql.NullInt64
if err := rows.Scan(&id, &name, &v.From, &v.Class, &price); err != nil {
rows.Close()
return nil, err
}
if price.Valid {
v.Price = &price.Int64
}
c.names[id] = name
c.versions[id] = append(c.versions[id], v)
}
if err := rows.Close(); err != nil {
return nil, err
}
rows, err = l.db.QueryContext(ctx, `SELECT product_id, customer_id, valid_from, price FROM customer_prices
ORDER BY product_id, customer_id, valid_from`)
if err != nil {
return nil, err
}
defer rows.Close()
for rows.Next() {
var product, customer string
var v ProductVersion
var price int64
if err := rows.Scan(&product, &customer, &v.From, &price); err != nil {
return nil, err
}
v.Price = &price
k := [2]string{product, customer}
c.customers[k] = append(c.customers[k], v)
}
return c, rows.Err()
}
func versionAt(vs []ProductVersion, month string) (ProductVersion, bool) {
var out ProductVersion
found := false
for _, v := range vs {
if v.From <= month {
out, found = v, true
}
}
return out, found
}
// At liefert Klasse und Preis eines Produkts im Monat (JJJJ-MM) für einen
// Kunden; der Kundenpreis hat Vorrang vor dem Katalogpreis.
func (c *Catalog) At(product, customer, month string) (ProductVersion, bool) {
v, ok := versionAt(c.versions[product], month)
if !ok {
return ProductVersion{}, false
}
if cp, ok := versionAt(c.customers[[2]string{product, customer}], month); ok {
v.Price = cp.Price
}
v.From = ""
return v, true
}
// Name liefert den Namen eines Produkts ("" wenn unbekannt).
func (c *Catalog) Name(product string) string { return c.names[product] }
// ListProducts liefert alle Produkte mit Versionen; Class/Price sind die
// des aktuellen Monats.
func (l *Ledger) ListProducts(ctx context.Context) ([]Product, error) {
cat, err := l.Catalog(ctx)
if err != nil {
return nil, err
}
month := l.now().UTC().Format("2006-01")
var out []Product
for id, name := range cat.names {
p := Product{ID: id, Name: name, Versions: cat.versions[id]}
if v, ok := versionAt(p.Versions, month); ok {
p.Class, p.Price = v.Class, v.Price
}
out = append(out, p)
}
slices.SortFunc(out, func(a, b Product) int { return strings.Compare(a.ID, b.ID) })
return out, nil
}
// SetAssignment setzt (bzw. ersetzt) die Zuordnung eines Postens.
func (l *Ledger) SetAssignment(ctx context.Context, a Assignment) error {
switch {
case a.Tenant == "" || a.Router == "" || (a.Protocol != "http" && a.Protocol != "tcp"):
return invalid("Posten unvollständig (Mandant, Protokoll http/tcp, Router)")
case a.Product == "" && a.Class == "" && a.Start == "":
return invalid("nichts zugeordnet (Produkt, Klasse oder Beginn angeben)")
case a.Class != "" && !ValidClass(a.Class):
return invalid("Klasse %q unbekannt", a.Class)
case a.Start != "" && !ValidMonth(a.Start):
return invalid("Beginn %q ungültig (JJJJ-MM)", a.Start)
}
if a.Product != "" {
if err := l.requireProduct(ctx, a.Product); err != nil {
return err
}
}
_, err := l.db.ExecContext(ctx, `INSERT INTO assignments (tenant, protocol, router, product_id, class, start)
VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT(tenant, protocol, router) DO UPDATE SET
product_id = excluded.product_id, class = excluded.class, start = excluded.start`,
a.Tenant, a.Protocol, a.Router, a.Product, a.Class, a.Start)
return err
}
// ListAssignments liefert alle Zuordnungen, sortiert nach Mandant, Router.
func (l *Ledger) ListAssignments(ctx context.Context) ([]Assignment, error) {
rows, err := l.db.QueryContext(ctx, `SELECT tenant, protocol, router, product_id, class, start FROM assignments
ORDER BY tenant, router, protocol`)
if err != nil {
return nil, err
}
defer rows.Close()
var out []Assignment
for rows.Next() {
var a Assignment
if err := rows.Scan(&a.Tenant, &a.Protocol, &a.Router, &a.Product, &a.Class, &a.Start); err != nil {
return nil, err
}
out = append(out, a)
}
return out, rows.Err()
}
// DeleteAssignment entfernt die Zuordnung eines Postens.
func (l *Ledger) DeleteAssignment(ctx context.Context, tenant, protocol, router string) error {
res, err := l.db.ExecContext(ctx, `DELETE FROM assignments WHERE tenant = ? AND protocol = ? AND router = ?`,
tenant, protocol, router)
if err != nil {
return err
}
if n, err := res.RowsAffected(); err != nil {
return err
} else if n == 0 {
return fmt.Errorf("Zuordnung %s/%s/%s: %w", tenant, protocol, router, store.ErrNotFound)
}
return nil
}
+124
View File
@@ -0,0 +1,124 @@
package ledger
import (
"errors"
"testing"
"sultan/pkg/store"
)
func cents(v int64) *int64 { return &v }
// Klasse und Preis gelten ab einem Monat; Kundenpreise haben Vorrang.
func TestProducts_Versions(t *testing.T) {
l := open(t)
if err := l.AddProduct(ctx, Product{ID: "pingvin", Name: "Pingvin Share", Class: ClassFree}); err != nil {
t.Fatal(err)
}
if err := l.AddProduct(ctx, Product{ID: "olat", Name: "OLAT", Class: ClassBillable, Price: cents(4900)}); err != nil {
t.Fatal(err)
}
if err := l.SetProductVersion(ctx, "pingvin", "2027-03", ClassBillable, cents(500)); err != nil {
t.Fatal(err)
}
l.AddCustomer(ctx, Customer{ID: "k-1001", Name: "Kunde A"})
if err := l.SetCustomerPrice(ctx, "olat", "k-1001", "2026-11", 3900); err != nil {
t.Fatal(err)
}
cat, err := l.Catalog(ctx)
if err != nil {
t.Fatal(err)
}
tests := []struct {
product, customer, month string
class string
price *int64
}{
{"pingvin", "", "2027-02", ClassFree, nil},
{"pingvin", "", "2027-03", ClassBillable, cents(500)},
{"pingvin", "", "2028-01", ClassBillable, cents(500)},
{"olat", "", "2026-10", ClassBillable, cents(4900)},
{"olat", "k-1001", "2026-10", ClassBillable, cents(4900)}, // Kundenpreis erst ab November
{"olat", "k-1001", "2026-11", ClassBillable, cents(3900)},
{"olat", "andere", "2026-11", ClassBillable, cents(4900)},
}
for _, tt := range tests {
v, ok := cat.At(tt.product, tt.customer, tt.month)
if !ok || v.Class != tt.class || !samePrice(v.Price, tt.price) {
t.Errorf("At(%s, %s, %s) = %+v %v, erwartet %s %v", tt.product, tt.customer, tt.month, v, ok, tt.class, deref(tt.price))
}
}
if _, ok := cat.At("gibts-nicht", "", "2026-10"); ok {
t.Error("unbekanntes Produkt gefunden")
}
list, _ := l.ListProducts(ctx)
if len(list) != 2 || list[0].ID != "olat" {
t.Errorf("ListProducts: %+v", list)
}
for _, err := range []error{
l.AddProduct(ctx, Product{ID: "olat", Name: "x", Class: ClassFree}),
} {
if !errors.Is(err, store.ErrExists) {
t.Errorf("doppelt: %v", err)
}
}
for _, err := range []error{
l.AddProduct(ctx, Product{ID: "x1", Name: "x", Class: "teuer"}),
l.AddProduct(ctx, Product{ID: "x2", Name: "", Class: ClassFree}),
l.SetProductVersion(ctx, "olat", "2026-13", ClassFree, nil),
l.SetCustomerPrice(ctx, "olat", "k-1001", "2026/11", 100),
} {
if !errors.Is(err, store.ErrInvalid) {
t.Errorf("ungültig: %v", err)
}
}
if err := l.SetProductVersion(ctx, "gibts-nicht", "2026-11", ClassFree, nil); !errors.Is(err, store.ErrNotFound) {
t.Errorf("unbekanntes Produkt: %v", err)
}
}
func samePrice(a, b *int64) bool { return (a == nil && b == nil) || (a != nil && b != nil && *a == *b) }
func deref(p *int64) any {
if p == nil {
return nil
}
return *p
}
// Zuordnung je Posten (Mandant, Protokoll, Router).
func TestAssignments(t *testing.T) {
l := open(t)
l.AddProduct(ctx, Product{ID: "olat", Name: "OLAT", Class: ClassBillable})
a := Assignment{Tenant: "kunde", Protocol: "http", Router: "olat", Product: "olat", Start: "2026-11"}
if err := l.SetAssignment(ctx, a); err != nil {
t.Fatal(err)
}
a2 := Assignment{Tenant: "kunde", Protocol: "http", Router: "share", Class: ClassInternal}
l.SetAssignment(ctx, a2)
got, err := l.ListAssignments(ctx)
if err != nil || len(got) != 2 || got[0] != a || got[1] != a2 {
t.Errorf("ListAssignments = %+v, %v", got, err)
}
a.Start = ""
l.SetAssignment(ctx, a) // ersetzt
if got, _ := l.ListAssignments(ctx); got[0].Start != "" {
t.Errorf("nicht ersetzt: %+v", got[0])
}
for _, bad := range []Assignment{
{Tenant: "kunde", Protocol: "http", Router: "x", Product: "gibts-nicht"},
{Tenant: "kunde", Protocol: "http", Router: "x", Class: "teuer"},
{Tenant: "kunde", Protocol: "http", Router: "x", Start: "November"},
{Tenant: "kunde", Protocol: "http", Router: "x"}, // nichts zugeordnet
} {
if err := l.SetAssignment(ctx, bad); err == nil {
t.Errorf("%+v akzeptiert", bad)
}
}
if err := l.DeleteAssignment(ctx, "kunde", "http", "share"); err != nil {
t.Fatal(err)
}
if err := l.DeleteAssignment(ctx, "kunde", "http", "share"); !errors.Is(err, store.ErrNotFound) {
t.Errorf("zweites Löschen: %v", err)
}
}