Accountant: Inventar, where, Checkmk, Abrechnung
This commit is contained in:
1 parent
9f16148cde
commit
dd93277750
59 files changed
+7522
-113
No files matched your search
@@ -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 ""
|
||||
}
|
||||
@@ -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 = ¢s
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user