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 }