Files

300 lines
8.5 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 ""
}