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