diff --git a/CLAUDE.md b/CLAUDE.md index f13ad15..ba41d8c 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -679,6 +679,45 @@ Zeile mit dem aktuellen Stand, eine neu registrierte Firma ohne eigene --- +## Firmendaten: Adresse und Abrechnung (2026-09-01, Migration 0023) + +Auf Nutzerwunsch: bei jeder Firmenanlage (öffentliche Registrierung +`POST /register` UND Betreiber-Firmenanlage `POST /betreiber/accounts`) +müssen jetzt vollständige Firmendaten erfasst werden, nicht nur der +Name. `account` bekommt sechs neue Spalten: `strasse`, `plz`, `ort`, +`land` (Adresse) sowie `ust_id`, `rechnungsemail` (Abrechnung). +Pflichtfelder bei Neuanlage: Name, Straße, PLZ, Ort, Land, +Rechnungsemail. **Bewusst optional:** USt-IdNr. — Kleinunternehmer nach +§19 UStG haben keine, eine Pflichtangabe wäre hier fachlich falsch. + +`store.AccountInput` bündelt diese Felder für `CreateAccount` (Signatur +geändert von `(ctx, name string)` auf `(ctx, AccountInput)` — betrifft +~20 Testaufrufe, mechanisch auf `AccountInput{Name: "..."}` umgestellt) +und die neue Methode `UpdateAccountDetails` (Name + alle Firmendaten in +einem Aufruf, getrennt von der bestehenden `UpdateAccount`, die nur den +Namen ändert — z. B. für die schnelle Tippfehlerkorrektur durch den +Betreiber). Migrationsspalten sind `NOT NULL DEFAULT ''` statt einer +harten Pflicht ohne Default: bestehende, vor dieser Migration angelegte +Accounts haben diese Daten schlicht noch nicht, die Migration darf sie +nicht blockieren — die Pflicht gilt nur auf Anwendungsebene für NEUE +Firmen. + +**Neue Seite für bestehende Firmen:** `GET/POST /verwaltung/firma` +(Ebene 4, admin-only, eigener Nav-Punkt "Firmendaten") — zum Einsehen +und Nachtragen/Korrigieren der eigenen Adress-/Abrechnungsdaten, auch +für Accounts, die vor dieser Migration entstanden sind und die Felder +sonst dauerhaft leer hätten. `register.html`/`betreiber_account_neu.html` +wurden dabei auf das `.form-card`/`.form-section`/`.form-grid`-Muster +umgestellt (vorher unstylte Rohformulare) — bei sieben-plus Feldern +sonst unübersichtlich. + +Live end-to-end verifiziert: beide Anlage-Wege (Registrierung UND +Betreiber-Firmenanlage) speichern alle Felder korrekt, die neue +Firmendaten-Seite zeigt den aktuellen Stand vorausgefüllt und +Änderungen werden korrekt persistiert. + +--- + ## Row-Level-Security (2026-09-01, Migration 0021) **Ausgangslage:** RLS-Policies wirken nie bei Postgres-Superusern, und diff --git a/internal/store/account.go b/internal/store/account.go index a793513..b1f7ece 100644 --- a/internal/store/account.go +++ b/internal/store/account.go @@ -13,30 +13,56 @@ import ( // nutzt). Jeder Antrag gehört genau einem Account. EinladungToken ist // der Sammellink für die Mitarbeiter-Selbstanmeldung (Ebene 1, // "Einladung annehmen") — ein Token pro Account, per Admin erneuerbar. +// Strasse/PLZ/Ort/Land/UStID/Rechnungsemail sind die Firmen- und +// Abrechnungsdaten (Migration 0023) — bei bestehenden, vor dieser +// Migration angelegten Accounts können sie leer sein, siehe dort. type Account struct { ID string Name string EinladungToken string + Strasse string + PLZ string + Ort string + Land string + UStID string + Rechnungsemail string CreatedAt time.Time } -const accountColumns = `id, name, einladung_token, created_at` +// AccountInput bündelt die Firmendaten für CreateAccount/ +// UpdateAccountDetails. Name ist die einzige Pflichtangabe auf +// Store-Ebene — welche der übrigen Felder ein Formular tatsächlich +// verlangt (z. B. Adresse bei Neuanlage), entscheidet die Web-Schicht, +// nicht der Store (Tests legen Accounts oft ohne vollständige +// Firmendaten an, das ist auf Store-Ebene kein Fehler). +type AccountInput struct { + Name string + Strasse string + PLZ string + Ort string + Land string + UStID string + Rechnungsemail string +} + +const accountColumns = `id, name, einladung_token, strasse, plz, ort, land, ust_id, rechnungsemail, created_at` func scanAccount(row interface { Scan(dest ...any) error }) (Account, error) { var a Account - err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.CreatedAt) + err := row.Scan(&a.ID, &a.Name, &a.EinladungToken, &a.Strasse, &a.PLZ, &a.Ort, &a.Land, &a.UStID, &a.Rechnungsemail, &a.CreatedAt) return a, err } // CreateAccount legt einen neuen Mandanten an. einladung_token wird von // der Datenbank per DEFAULT erzeugt (siehe Migration 0013). -func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) { +func (s *Store) CreateAccount(ctx context.Context, in AccountInput) (Account, error) { row := s.db(ctx).QueryRow(ctx, ` - INSERT INTO account (name) VALUES ($1) + INSERT INTO account (name, strasse, plz, ort, land, ust_id, rechnungsemail) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING `+accountColumns, - name, + in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail, ) a, err := scanAccount(row) if err != nil { @@ -46,7 +72,8 @@ func (s *Store) CreateAccount(ctx context.Context, name string) (Account, error) } // UpdateAccount benennt einen Mandanten um (z. B. Tippfehler bei der -// Betreiber-gestützten Anlage korrigieren). +// Betreiber-gestützten Anlage korrigieren) — ändert bewusst nur den +// Namen, nicht die übrigen Firmendaten, siehe UpdateAccountDetails. func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, error) { row := s.db(ctx).QueryRow(ctx, `UPDATE account SET name = $2 WHERE id = $1 RETURNING `+accountColumns, id, name) a, err := scanAccount(row) @@ -59,6 +86,26 @@ func (s *Store) UpdateAccount(ctx context.Context, id, name string) (Account, er return a, nil } +// UpdateAccountDetails aktualisiert Name und Firmen-/Abrechnungsdaten +// gemeinsam — genutzt von der Firmendaten-Seite (Ebene 4, admin), auf +// der ein Mandant seine eigenen Angaben pflegt/nachträgt. +func (s *Store) UpdateAccountDetails(ctx context.Context, id string, in AccountInput) (Account, error) { + row := s.db(ctx).QueryRow(ctx, ` + UPDATE account SET name = $2, strasse = $3, plz = $4, ort = $5, land = $6, ust_id = $7, rechnungsemail = $8 + WHERE id = $1 + RETURNING `+accountColumns, + id, in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail, + ) + a, err := scanAccount(row) + if errors.Is(err, pgx.ErrNoRows) { + return Account{}, ErrNotFound + } + if err != nil { + return Account{}, fmt.Errorf("store: update account details: %w", err) + } + return a, nil +} + // GetAccount liest einen Mandanten anhand seiner ID. func (s *Store) GetAccount(ctx context.Context, id string) (Account, error) { row := s.db(ctx).QueryRow(ctx, `SELECT `+accountColumns+` FROM account WHERE id = $1`, id) diff --git a/internal/store/admin_test.go b/internal/store/admin_test.go index 1505b39..adbcdb6 100644 --- a/internal/store/admin_test.go +++ b/internal/store/admin_test.go @@ -3,6 +3,8 @@ package store_test import ( "context" "testing" + + "github.com/netcell-it/deklarix/internal/store" ) func TestAppUserRoleAllowsBetreiber(t *testing.T) { @@ -37,7 +39,7 @@ func TestListAccounts(t *testing.T) { if err != nil { t.Fatalf("ListAccounts: %v", err) } - acc, err := s.CreateAccount(ctx, "Neuer Mandant fuer ListAccounts") + acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Neuer Mandant fuer ListAccounts"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } diff --git a/internal/store/auth_test.go b/internal/store/auth_test.go index d447c6a..eb5bf86 100644 --- a/internal/store/auth_test.go +++ b/internal/store/auth_test.go @@ -13,7 +13,7 @@ func TestAccountCRUD(t *testing.T) { s := openTestStore(t) ctx := context.Background() - acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH") + acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Beispiel Agentur GmbH"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } @@ -30,10 +30,63 @@ func TestAccountCRUD(t *testing.T) { } } +func TestCreateAccountMitFirmendaten(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + acc, err := s.CreateAccount(ctx, store.AccountInput{ + Name: "Vollstaendig GmbH", Strasse: "Musterstraße 1", PLZ: "12345", Ort: "Musterstadt", + Land: "Deutschland", UStID: "DE123456789", Rechnungsemail: "rechnung@vollstaendig.example.com", + }) + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + if acc.Strasse != "Musterstraße 1" || acc.PLZ != "12345" || acc.Ort != "Musterstadt" || + acc.Land != "Deutschland" || acc.UStID != "DE123456789" || acc.Rechnungsemail != "rechnung@vollstaendig.example.com" { + t.Fatalf("Account = %+v, Firmendaten unvollständig gespeichert", acc) + } + + got, err := s.GetAccount(ctx, acc.ID) + if err != nil { + t.Fatalf("GetAccount: %v", err) + } + if got.Rechnungsemail != acc.Rechnungsemail { + t.Fatalf("Rechnungsemail nach GetAccount = %q, want %q", got.Rechnungsemail, acc.Rechnungsemail) + } +} + +func TestUpdateAccountDetails(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Alte Firma", Ort: "Altstadt"}) + if err != nil { + t.Fatalf("CreateAccount: %v", err) + } + + updated, err := s.UpdateAccountDetails(ctx, acc.ID, store.AccountInput{ + Name: "Neue Firma", Strasse: "Neue Straße 2", PLZ: "54321", Ort: "Neustadt", + Land: "Österreich", UStID: "", Rechnungsemail: "buchhaltung@neue-firma.example.com", + }) + if err != nil { + t.Fatalf("UpdateAccountDetails: %v", err) + } + if updated.Name != "Neue Firma" || updated.Ort != "Neustadt" || updated.Land != "Österreich" { + t.Fatalf("Account nach Update = %+v, nicht wie erwartet aktualisiert", updated) + } +} + +func TestUpdateAccountDetailsNotFound(t *testing.T) { + s := openTestStore(t) + ctx := context.Background() + _, err := s.UpdateAccountDetails(ctx, "00000000-0000-0000-0000-000000000000", store.AccountInput{Name: "X"}) + if !errors.Is(err, store.ErrNotFound) { + t.Fatalf("err = %v, want ErrNotFound", err) + } +} + func TestUpdateAccount(t *testing.T) { s := openTestStore(t) ctx := context.Background() - acc, err := s.CreateAccount(ctx, "Alter Name GmbH") + acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Alter Name GmbH"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } @@ -66,7 +119,7 @@ func TestUpdateAccountNotFound(t *testing.T) { func TestGetAccountByEinladungToken(t *testing.T) { s := openTestStore(t) ctx := context.Background() - acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH") + acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Beispiel Agentur GmbH"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } @@ -88,7 +141,7 @@ func TestGetAccountByEinladungToken(t *testing.T) { func TestRegenerateEinladungToken(t *testing.T) { s := openTestStore(t) ctx := context.Background() - acc, err := s.CreateAccount(ctx, "Beispiel Agentur GmbH") + acc, err := s.CreateAccount(ctx, store.AccountInput{Name: "Beispiel Agentur GmbH"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } diff --git a/internal/store/migrations/0023_account_firmendaten.down.sql b/internal/store/migrations/0023_account_firmendaten.down.sql new file mode 100644 index 0000000..76952b7 --- /dev/null +++ b/internal/store/migrations/0023_account_firmendaten.down.sql @@ -0,0 +1,6 @@ +ALTER TABLE account DROP COLUMN strasse; +ALTER TABLE account DROP COLUMN plz; +ALTER TABLE account DROP COLUMN ort; +ALTER TABLE account DROP COLUMN land; +ALTER TABLE account DROP COLUMN ust_id; +ALTER TABLE account DROP COLUMN rechnungsemail; diff --git a/internal/store/migrations/0023_account_firmendaten.up.sql b/internal/store/migrations/0023_account_firmendaten.up.sql new file mode 100644 index 0000000..f354ebc --- /dev/null +++ b/internal/store/migrations/0023_account_firmendaten.up.sql @@ -0,0 +1,20 @@ +-- Adress- und Abrechnungsdaten der Firma — bisher trug account nur den +-- Namen. NOT NULL DEFAULT '' statt einer harten NOT-NULL-Pflicht ohne +-- Default: bestehende Accounts (vor dieser Migration angelegt) haben +-- diese Daten schlicht noch nicht, das darf die Migration nicht +-- blockieren. Die Anwendungsschicht erzwingt Pflichtfelder nur für NEU +-- angelegte Firmen (Registrierung, Betreiber-Firmenanlage); bestehende +-- Firmen werden nicht rückwirkend gezwungen, sie können es über die +-- neue Firmendaten-Seite (Ebene 4) nachtragen. +ALTER TABLE account ADD COLUMN strasse TEXT NOT NULL DEFAULT ''; +ALTER TABLE account ADD COLUMN plz TEXT NOT NULL DEFAULT ''; +ALTER TABLE account ADD COLUMN ort TEXT NOT NULL DEFAULT ''; +ALTER TABLE account ADD COLUMN land TEXT NOT NULL DEFAULT ''; +-- Umsatzsteuer-ID ist bewusst optional (NOT NULL DEFAULT '', keine +-- Pflicht auch bei Neuanlage) — Kleinunternehmer nach §19 UStG haben +-- keine. +ALTER TABLE account ADD COLUMN ust_id TEXT NOT NULL DEFAULT ''; +-- Rechnungsemail kann von der E-Mail des ersten (admin-)Logins +-- abweichen (z. B. eine buchhaltung@-Adresse) — eigenes Feld statt +-- Wiederverwendung der Login-E-Mail. +ALTER TABLE account ADD COLUMN rechnungsemail TEXT NOT NULL DEFAULT ''; diff --git a/internal/store/store_test.go b/internal/store/store_test.go index 3dcf4f6..70c5d91 100644 --- a/internal/store/store_test.go +++ b/internal/store/store_test.go @@ -36,7 +36,7 @@ func openTestStore(t *testing.T) *store.Store { // testAccountID legt einen Mandanten an und liefert dessen ID. func testAccountID(t *testing.T, s *store.Store) string { t.Helper() - acc, err := s.CreateAccount(context.Background(), "Test-Mandant") + acc, err := s.CreateAccount(context.Background(), store.AccountInput{Name: "Test-Mandant"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } diff --git a/internal/web/auth_handlers.go b/internal/web/auth_handlers.go index 5761ef5..26dca0e 100644 --- a/internal/web/auth_handlers.go +++ b/internal/web/auth_handlers.go @@ -6,6 +6,7 @@ import ( "time" "github.com/netcell-it/deklarix/internal/auth" + "github.com/netcell-it/deklarix/internal/store" ) type authPageData struct { @@ -46,11 +47,21 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { return } - accountName := r.FormValue("account_name") + accountInput := store.AccountInput{ + Name: r.FormValue("account_name"), + Strasse: r.FormValue("strasse"), + PLZ: r.FormValue("plz"), + Ort: r.FormValue("ort"), + Land: r.FormValue("land"), + UStID: r.FormValue("ust_id"), // optional, siehe Migration 0023 (Kleinunternehmer) + Rechnungsemail: r.FormValue("rechnungsemail"), + } email := r.FormValue("email") password := r.FormValue("password") - if accountName == "" || email == "" || password == "" { - s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder sind Pflicht"}) + if accountInput.Name == "" || email == "" || password == "" || + accountInput.Strasse == "" || accountInput.PLZ == "" || accountInput.Ort == "" || + accountInput.Land == "" || accountInput.Rechnungsemail == "" { + s.renderAuthPage(w, "register", authPageData{Title: "Registrieren", Error: "Alle Felder außer USt-IdNr. sind Pflicht"}) return } @@ -68,7 +79,7 @@ func (s *Server) handleRegister(w http.ResponseWriter, r *http.Request) { // RLS-geschützt) von der Policy abgelehnt. var errMsg string err = s.store.WithTenantScope(r.Context(), "", false, func(ctx context.Context) error { - acc, err := s.store.CreateAccount(ctx, accountName) + acc, err := s.store.CreateAccount(ctx, accountInput) if err != nil { errMsg = "Konto konnte nicht angelegt werden" return err diff --git a/internal/web/betreiber_account_crud_test.go b/internal/web/betreiber_account_crud_test.go index 370664f..d256072 100644 --- a/internal/web/betreiber_account_crud_test.go +++ b/internal/web/betreiber_account_crud_test.go @@ -6,6 +6,8 @@ import ( "net/url" "strings" "testing" + + "github.com/netcell-it/deklarix/internal/store" ) func TestBetreiberKannFirmaAnlegen(t *testing.T) { @@ -13,9 +15,9 @@ func TestBetreiberKannFirmaAnlegen(t *testing.T) { s := newServer(t, fs) betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") - resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", url.Values{ + resp := postForm(t, s, betreiberCookie, "/betreiber/accounts", mergeValues(url.Values{ "account_name": {"Neue Firma GmbH"}, "email": {"admin@neue-firma.example.com"}, "password": {"ein-langes-passwort"}, - }) + }, firmenPflichtfelder())) if resp.Code != http.StatusSeeOther { t.Fatalf("status = %d, body: %s", resp.Code, resp.Body.String()) } @@ -62,7 +64,7 @@ func TestBetreiberKannFirmaUmbenennen(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") - acc, err := fs.CreateAccount(context.Background(), "Alter Name") + acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Alter Name"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } diff --git a/internal/web/betreiber_handlers.go b/internal/web/betreiber_handlers.go index b802b5c..6c9586d 100644 --- a/internal/web/betreiber_handlers.go +++ b/internal/web/betreiber_handlers.go @@ -111,11 +111,21 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req http.Error(w, "ungültiges Formular", http.StatusBadRequest) return } - accountName := r.FormValue("account_name") + accountInput := store.AccountInput{ + Name: r.FormValue("account_name"), + Strasse: r.FormValue("strasse"), + PLZ: r.FormValue("plz"), + Ort: r.FormValue("ort"), + Land: r.FormValue("land"), + UStID: r.FormValue("ust_id"), // optional, siehe Migration 0023 (Kleinunternehmer) + Rechnungsemail: r.FormValue("rechnungsemail"), + } email := r.FormValue("email") password := r.FormValue("password") - if accountName == "" || email == "" || password == "" { - data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Alle Felder sind Pflicht"} + if accountInput.Name == "" || email == "" || password == "" || + accountInput.Strasse == "" || accountInput.PLZ == "" || accountInput.Ort == "" || + accountInput.Land == "" || accountInput.Rechnungsemail == "" { + data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Alle Felder außer USt-IdNr. sind Pflicht"} if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil { http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) } @@ -132,7 +142,7 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req } ctx := r.Context() - acc, err := s.store.CreateAccount(ctx, accountName) + acc, err := s.store.CreateAccount(ctx, accountInput) if err != nil { data := betreiberAccountNeuData{Title: "Firma anlegen", Nav: navFor(r), Error: "Konto konnte nicht angelegt werden: " + err.Error()} if err := s.templates.ExecuteTemplate(w, "betreiber-account-neu", data); err != nil { @@ -161,7 +171,7 @@ func (s *Server) handleBetreiberAccountCreate(w http.ResponseWriter, r *http.Req } return } - if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountName+" / "+email); err != nil { + if _, err := s.store.CreateAuditEntry(ctx, currentUser(r).ID, "betreiber_firma_angelegt", "account", acc.ID, accountInput.Name+" / "+email); err != nil { http.Error(w, "Audit-Log konnte nicht geschrieben werden: "+err.Error(), http.StatusInternalServerError) return } diff --git a/internal/web/betreiber_werkzeug_handlers_test.go b/internal/web/betreiber_werkzeug_handlers_test.go index 7c911ea..a20bbf0 100644 --- a/internal/web/betreiber_werkzeug_handlers_test.go +++ b/internal/web/betreiber_werkzeug_handlers_test.go @@ -106,7 +106,7 @@ func TestBetreiberCannotEditMandantenEigenesWerkzeug(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) betreiberCookie := seedAccountWithRole(t, fs, "Deklarix Betreiber", "betreiber@example.com", "betreiber") - acc, err := fs.CreateAccount(context.Background(), "Mandant mit eigenem Werkzeug") + acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Mandant mit eigenem Werkzeug"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } diff --git a/internal/web/einladung_handlers_test.go b/internal/web/einladung_handlers_test.go index 839eeda..423b2c6 100644 --- a/internal/web/einladung_handlers_test.go +++ b/internal/web/einladung_handlers_test.go @@ -6,12 +6,14 @@ import ( "net/url" "strings" "testing" + + "github.com/netcell-it/deklarix/internal/store" ) func TestEinladungFormZeigtFirmenname(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) - acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH") + acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Beispiel GmbH"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } @@ -41,7 +43,7 @@ func TestEinladungMitUnbekanntemTokenZeigtFehler(t *testing.T) { func TestEinladungAnnehmenLegtMitarbeiterAn(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) - acc, err := fs.CreateAccount(context.Background(), "Beispiel GmbH") + acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Beispiel GmbH"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } diff --git a/internal/web/fachebene_handlers_test.go b/internal/web/fachebene_handlers_test.go index 4e6c6be..bb14df5 100644 --- a/internal/web/fachebene_handlers_test.go +++ b/internal/web/fachebene_handlers_test.go @@ -19,7 +19,7 @@ import ( func seedFallImAccount(t *testing.T, fs *fakeStore, s *web.Server, role string) (antragID string, fachebeneCookie *http.Cookie) { t.Helper() ctx := context.Background() - acc, err := fs.CreateAccount(ctx, "Fachebene-Mandant") + acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: "Fachebene-Mandant"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } @@ -62,7 +62,7 @@ func TestFaelleListeShowsOpenAntraegeForAccount(t *testing.T) { } // Ein Verantwortlicher eines anderen Mandanten darf ihn nicht sehen. - otherAcc, err := fs.CreateAccount(context.Background(), "Anderer Mandant") + otherAcc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Anderer Mandant"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } @@ -174,7 +174,7 @@ func TestFallEntscheidenRueckfrageOhneAbweichungBrauchtKeineBegruendung(t *testi func TestFallEntscheidenGenehmigtBrauchtZulaessigesWerkzeug(t *testing.T) { fs := newFakeStore() - acc, err := fs.CreateAccount(context.Background(), "Mit Katalog") + acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Mit Katalog"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } @@ -225,7 +225,7 @@ func TestFallDetailRejectsForeignAccount(t *testing.T) { s := newServer(t, fs) antragID, _ := seedFallImAccount(t, fs, s, "verantwortlicher") - otherAcc, err := fs.CreateAccount(context.Background(), "Fremder Mandant") + otherAcc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Fremder Mandant"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } diff --git a/internal/web/firmendaten_handlers.go b/internal/web/firmendaten_handlers.go new file mode 100644 index 0000000..ede9ef7 --- /dev/null +++ b/internal/web/firmendaten_handlers.go @@ -0,0 +1,74 @@ +// Firmendaten (Migration 0023) — Adress- und Abrechnungsdaten des +// eigenen Mandanten einsehen/pflegen. Bei der Neuanlage (Registrierung, +// Betreiber-Firmenanlage) sind diese Felder Pflicht; bestehende, davor +// angelegte Accounts können sie hier nachtragen. +package web + +import ( + "net/http" + + "github.com/netcell-it/deklarix/internal/store" +) + +type firmendatenData struct { + Title string + Nav navData + Name string + Strasse string + PLZ string + Ort string + Land string + UStID string + Rechnungsemail string + Error string + Gespeichert bool +} + +func (s *Server) handleFirmendaten(w http.ResponseWriter, r *http.Request) { + acc, err := s.store.GetAccount(r.Context(), currentUser(r).AccountID) + if err != nil { + http.Error(w, "Firmendaten konnten nicht geladen werden: "+err.Error(), http.StatusInternalServerError) + return + } + data := firmendatenData{ + Title: "Firmendaten", Nav: navFor(r), + Name: acc.Name, Strasse: acc.Strasse, PLZ: acc.PLZ, Ort: acc.Ort, + Land: acc.Land, UStID: acc.UStID, Rechnungsemail: acc.Rechnungsemail, + Gespeichert: r.URL.Query().Get("gespeichert") == "1", + } + if err := s.templates.ExecuteTemplate(w, "firmendaten", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } +} + +func (s *Server) handleFirmendatenSpeichern(w http.ResponseWriter, r *http.Request) { + if err := r.ParseForm(); err != nil { + http.Error(w, "ungültiges Formular", http.StatusBadRequest) + return + } + in := store.AccountInput{ + Name: r.FormValue("account_name"), + Strasse: r.FormValue("strasse"), + PLZ: r.FormValue("plz"), + Ort: r.FormValue("ort"), + Land: r.FormValue("land"), + UStID: r.FormValue("ust_id"), + Rechnungsemail: r.FormValue("rechnungsemail"), + } + if in.Name == "" || in.Strasse == "" || in.PLZ == "" || in.Ort == "" || in.Land == "" || in.Rechnungsemail == "" { + data := firmendatenData{ + Title: "Firmendaten", Nav: navFor(r), Error: "Alle Felder außer USt-IdNr. sind Pflicht", + Name: in.Name, Strasse: in.Strasse, PLZ: in.PLZ, Ort: in.Ort, Land: in.Land, + UStID: in.UStID, Rechnungsemail: in.Rechnungsemail, + } + if err := s.templates.ExecuteTemplate(w, "firmendaten", data); err != nil { + http.Error(w, "Seite konnte nicht gerendert werden", http.StatusInternalServerError) + } + return + } + if _, err := s.store.UpdateAccountDetails(r.Context(), currentUser(r).AccountID, in); err != nil { + http.Error(w, "Speichern fehlgeschlagen: "+err.Error(), http.StatusInternalServerError) + return + } + http.Redirect(w, r, "/verwaltung/firma?gespeichert=1", http.StatusSeeOther) +} diff --git a/internal/web/firmendaten_handlers_test.go b/internal/web/firmendaten_handlers_test.go new file mode 100644 index 0000000..3a20f16 --- /dev/null +++ b/internal/web/firmendaten_handlers_test.go @@ -0,0 +1,87 @@ +package web_test + +import ( + "context" + "net/http" + "net/url" + "strings" + "testing" +) + +func TestRegisterRequiresFirmenadresse(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + + resp := postForm(t, s, nil, "/register", url.Values{ + "account_name": {"Unvollständig GmbH"}, "email": {"unvollstaendig@example.com"}, "password": {"ein-sicheres-passwort"}, + // Adresse/Rechnungsemail fehlen bewusst. + }) + if resp.Code != http.StatusOK { + t.Fatalf("status = %d, want 200 (Formular mit Fehler)", resp.Code) + } + if !strings.Contains(resp.Body.String(), "Pflicht") { + t.Fatalf("erwartet Validierungsfehler, body: %s", resp.Body.String()) + } +} + +func TestRegisterSpeichertFirmendaten(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + + resp := postForm(t, s, nil, "/register", mergeValues(url.Values{ + "account_name": {"Vollständig GmbH"}, "email": {"admin@vollstaendig.example.com"}, "password": {"ein-sicheres-passwort"}, + }, firmenPflichtfelder())) + if resp.Code != http.StatusSeeOther { + t.Fatalf("status = %d, want 303, body: %s", resp.Code, resp.Body.String()) + } + + user, err := fs.GetUserByEmail(context.Background(), "admin@vollstaendig.example.com") + if err != nil { + t.Fatalf("GetUserByEmail: %v", err) + } + acc, err := fs.GetAccount(context.Background(), user.AccountID) + if err != nil { + t.Fatalf("GetAccount: %v", err) + } + if acc.Strasse != "Teststraße 1" || acc.PLZ != "12345" || acc.Rechnungsemail != "rechnung@example.com" { + t.Fatalf("Account = %+v, Firmendaten nicht wie erwartet gespeichert", acc) + } +} + +func TestAdminKannFirmendatenBearbeiten(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + cookie := seedAccountWithRole(t, fs, "Alte Firma", "admin@example.com", "admin") + + getResp := getWithCookie(t, s, cookie, "/verwaltung/firma") + if getResp.Code != http.StatusOK { + t.Fatalf("GET status = %d, want 200", getResp.Code) + } + if !strings.Contains(getResp.Body.String(), "Alte Firma") { + t.Fatalf("erwartet aktuellen Firmennamen im Formular, body: %s", getResp.Body.String()) + } + + saveResp := postForm(t, s, cookie, "/verwaltung/firma", url.Values{ + "account_name": {"Neue Firma"}, "strasse": {"Neue Straße 5"}, "plz": {"99999"}, "ort": {"Neustadt"}, + "land": {"Deutschland"}, "rechnungsemail": {"buchhaltung@neue-firma.example.com"}, + }) + if saveResp.Code != http.StatusSeeOther { + t.Fatalf("save status = %d, want 303, body: %s", saveResp.Code, saveResp.Body.String()) + } + + afterResp := getWithCookie(t, s, cookie, "/verwaltung/firma") + if !strings.Contains(afterResp.Body.String(), "Neue Firma") || !strings.Contains(afterResp.Body.String(), "Neustadt") { + t.Fatalf("gespeicherte Firmendaten fehlen, body: %s", afterResp.Body.String()) + } +} + +func TestNichtAdminKannFirmendatenNichtBearbeiten(t *testing.T) { + fs := newFakeStore() + s := newServer(t, fs) + cookie := seedAccountWithRole(t, fs, "Test-Mandant", "mitarbeiter@example.com", "mitarbeiter") + + resp := getWithCookie(t, s, cookie, "/verwaltung/firma") + if resp.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404", resp.Code) + } +} diff --git a/internal/web/freigabe_handlers_test.go b/internal/web/freigabe_handlers_test.go index 4518e80..e9ad2da 100644 --- a/internal/web/freigabe_handlers_test.go +++ b/internal/web/freigabe_handlers_test.go @@ -7,6 +7,7 @@ import ( "strings" "testing" + "github.com/netcell-it/deklarix/internal/store" "github.com/netcell-it/deklarix/internal/web" ) @@ -32,7 +33,7 @@ func besondereKategorieAntragForm() url.Values { func seedFreigabeSzenario(t *testing.T, fs *fakeStore, s *web.Server) (accountID, antragID string, verantwortlicherCookie *http.Cookie) { t.Helper() ctx := context.Background() - acc, err := fs.CreateAccount(ctx, "Freigabe-Mandant") + acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: "Freigabe-Mandant"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } diff --git a/internal/web/passwort_reset_handlers_test.go b/internal/web/passwort_reset_handlers_test.go index 81ac036..96e2537 100644 --- a/internal/web/passwort_reset_handlers_test.go +++ b/internal/web/passwort_reset_handlers_test.go @@ -13,10 +13,10 @@ func TestPasswortVergessenSendetLinkUndSetztNeuesPasswort(t *testing.T) { fs := newFakeStore() s, fm := newServerWithMailer(t, fs) - regResp := postForm(t, s, nil, "/register", url.Values{ + regResp := postForm(t, s, nil, "/register", mergeValues(url.Values{ "account_name": {"Reset-Firma"}, "email": {"reset@example.com"}, "password": {"altes-passwort"}, - }) + }, firmenPflichtfelder())) if regResp.Code != http.StatusSeeOther { t.Fatalf("register status = %d, want 303", regResp.Code) } diff --git a/internal/web/register_handlers_test.go b/internal/web/register_handlers_test.go index 6b5a2a7..e796090 100644 --- a/internal/web/register_handlers_test.go +++ b/internal/web/register_handlers_test.go @@ -16,7 +16,7 @@ import ( // einen tatsächlichen Registereintrag brauchen. func genehmigeFall(t *testing.T, fs *fakeStore, s *web.Server) (accountID string) { t.Helper() - acc, err := fs.CreateAccount(context.Background(), "Register-Mandant") + acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Register-Mandant"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } diff --git a/internal/web/server.go b/internal/web/server.go index e069760..dfe6b20 100644 --- a/internal/web/server.go +++ b/internal/web/server.go @@ -38,8 +38,9 @@ type Store interface { WithTenantScope(ctx context.Context, accountID string, isBetreiber bool, fn func(ctx context.Context) error) error SetTenantScope(ctx context.Context, accountID string, isBetreiber bool) error - CreateAccount(ctx context.Context, name string) (store.Account, error) + CreateAccount(ctx context.Context, in store.AccountInput) (store.Account, error) UpdateAccount(ctx context.Context, id, name string) (store.Account, error) + UpdateAccountDetails(ctx context.Context, id string, in store.AccountInput) (store.Account, error) GetAccount(ctx context.Context, id string) (store.Account, error) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) RegenerateEinladungToken(ctx context.Context, accountID, newToken string) error @@ -166,6 +167,8 @@ func NewServer(st Store, regelwerk Regelwerk, mailer mail.Mailer) (*Server, erro mux.HandleFunc("POST /login", s.handleLogin) mux.HandleFunc("GET /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenListe)) mux.HandleFunc("POST /verwaltung/loeschfristen", s.requireAdmin(s.handleLoeschfristenSpeichern)) + mux.HandleFunc("GET /verwaltung/firma", s.requireAdmin(s.handleFirmendaten)) + mux.HandleFunc("POST /verwaltung/firma", s.requireAdmin(s.handleFirmendatenSpeichern)) mux.HandleFunc("GET /verwaltung/email-vorlagen", s.requireAdmin(s.handleEmailVorlagenListe)) mux.HandleFunc("GET /verwaltung/email-vorlagen/{typ}", s.requireAdmin(s.handleEmailVorlageBearbeitenForm)) mux.HandleFunc("POST /verwaltung/email-vorlagen/{typ}", s.requireAdmin(s.handleEmailVorlageSpeichern)) diff --git a/internal/web/server_test.go b/internal/web/server_test.go index f23f9d7..030d2c5 100644 --- a/internal/web/server_test.go +++ b/internal/web/server_test.go @@ -149,11 +149,15 @@ func (f *fakeStore) newID() string { return fmt.Sprintf("id-%d", f.nextID) } -func (f *fakeStore) CreateAccount(ctx context.Context, name string) (store.Account, error) { +func (f *fakeStore) CreateAccount(ctx context.Context, in store.AccountInput) (store.Account, error) { f.mu.Lock() defer f.mu.Unlock() id := f.newID() - acc := store.Account{ID: id, Name: name, EinladungToken: "einladung-token-" + id, CreatedAt: time.Now()} + acc := store.Account{ + ID: id, Name: in.Name, EinladungToken: "einladung-token-" + id, + Strasse: in.Strasse, PLZ: in.PLZ, Ort: in.Ort, Land: in.Land, + UStID: in.UStID, Rechnungsemail: in.Rechnungsemail, CreatedAt: time.Now(), + } f.accounts[acc.ID] = acc return acc, nil } @@ -170,6 +174,19 @@ func (f *fakeStore) UpdateAccount(ctx context.Context, id, name string) (store.A return acc, nil } +func (f *fakeStore) UpdateAccountDetails(ctx context.Context, id string, in store.AccountInput) (store.Account, error) { + f.mu.Lock() + defer f.mu.Unlock() + acc, ok := f.accounts[id] + if !ok { + return store.Account{}, store.ErrNotFound + } + acc.Name, acc.Strasse, acc.PLZ, acc.Ort, acc.Land, acc.UStID, acc.Rechnungsemail = + in.Name, in.Strasse, in.PLZ, in.Ort, in.Land, in.UStID, in.Rechnungsemail + f.accounts[id] = acc + return acc, nil +} + func (f *fakeStore) GetAccountByEinladungToken(ctx context.Context, token string) (store.Account, error) { f.mu.Lock() defer f.mu.Unlock() @@ -950,7 +967,7 @@ func newServerWithMailer(t *testing.T, fs *fakeStore) (*web.Server, *mail.FakeMa func seedAccountWithRole(t *testing.T, fs *fakeStore, accountName, email, role string) *http.Cookie { t.Helper() ctx := context.Background() - acc, err := fs.CreateAccount(ctx, accountName) + acc, err := fs.CreateAccount(ctx, store.AccountInput{Name: accountName}) if err != nil { t.Fatalf("CreateAccount: %v", err) } @@ -1010,6 +1027,28 @@ func newAuthedTestServer(t *testing.T) (*web.Server, *fakeStore, *http.Cookie) { return s, fs, cookie } +// firmenPflichtfelder liefert die seit Migration 0023 bei Firmenanlage +// (Registrierung und Betreiber-Firmenanlage) verlangten Adress-/ +// Abrechnungsfelder — gemeinsame Basis für Tests, die nicht speziell +// deren Validierung prüfen, sonst müsste jeder Testfall sie einzeln +// nachpflegen. +func firmenPflichtfelder() url.Values { + return url.Values{ + "strasse": {"Teststraße 1"}, "plz": {"12345"}, "ort": {"Teststadt"}, + "land": {"Deutschland"}, "rechnungsemail": {"rechnung@example.com"}, + } +} + +// mergeValues kopiert alle Werte aus extra in base (base wird mutiert +// und zurückgegeben) — für Tests, die ein Basisformular um wenige +// eigene Felder ergänzen wollen. +func mergeValues(base url.Values, extra url.Values) url.Values { + for k, v := range extra { + base[k] = v + } + return base +} + func postForm(t *testing.T, s *web.Server, cookie *http.Cookie, path string, form url.Values) *httptest.ResponseRecorder { t.Helper() req := httptest.NewRequest(http.MethodPost, path, strings.NewReader(form.Encode())) @@ -1071,10 +1110,10 @@ func TestRegisterThenLoginThenAccessProtectedPage(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) - regResp := postForm(t, s, nil, "/register", url.Values{ + regResp := postForm(t, s, nil, "/register", mergeValues(url.Values{ "account_name": {"Meine Firma"}, "email": {"neu@example.com"}, "password": {"ein-sicheres-passwort"}, - }) + }, firmenPflichtfelder())) if regResp.Code != http.StatusSeeOther { t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String()) } @@ -1101,10 +1140,10 @@ func TestRegisterSeedsStandardGenehmigerRollen(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) - regResp := postForm(t, s, nil, "/register", url.Values{ + regResp := postForm(t, s, nil, "/register", mergeValues(url.Values{ "account_name": {"Meine Firma"}, "email": {"neu2@example.com"}, "password": {"ein-sicheres-passwort"}, - }) + }, firmenPflichtfelder())) if regResp.Code != http.StatusSeeOther { t.Fatalf("register status = %d, want 303, body: %s", regResp.Code, regResp.Body.String()) } @@ -1147,10 +1186,10 @@ func TestRegisterSeedsStandardGenehmigerRollen(t *testing.T) { func TestRegisterRejectsDuplicateEmail(t *testing.T) { fs := newFakeStore() s := newServer(t, fs) - form := url.Values{ + form := mergeValues(url.Values{ "account_name": {"A"}, "email": {"doppelt@example.com"}, "password": {"ein-sicheres-passwort"}, - } + }, firmenPflichtfelder()) if resp := postForm(t, s, nil, "/register", form); resp.Code != http.StatusSeeOther { t.Fatalf("first register status = %d, want 303", resp.Code) } @@ -1170,7 +1209,7 @@ func TestLoginWithCorrectPassword(t *testing.T) { if err != nil { t.Fatalf("HashPassword: %v", err) } - acc, err := fs.CreateAccount(context.Background(), "Bestehender Mandant") + acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Bestehender Mandant"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } @@ -1194,7 +1233,7 @@ func TestLoginRedirectsBetreiberToPlatformDashboard(t *testing.T) { if err != nil { t.Fatalf("HashPassword: %v", err) } - acc, err := fs.CreateAccount(context.Background(), "Deklarix Betreiber") + acc, err := fs.CreateAccount(context.Background(), store.AccountInput{Name: "Deklarix Betreiber"}) if err != nil { t.Fatalf("CreateAccount: %v", err) } @@ -1218,7 +1257,7 @@ func TestLoginRejectsWrongPassword(t *testing.T) { if err != nil { t.Fatalf("HashPassword: %v", err) } - acc, _ := fs.CreateAccount(context.Background(), "X") + acc, _ := fs.CreateAccount(context.Background(), store.AccountInput{Name: "X"}) if _, err := fs.CreateUser(context.Background(), acc.ID, "x@example.com", hash, "mitarbeiter"); err != nil { t.Fatalf("CreateUser: %v", err) } diff --git a/internal/web/templates/betreiber_account_neu.html b/internal/web/templates/betreiber_account_neu.html index 50db08c..4752b67 100644 --- a/internal/web/templates/betreiber_account_neu.html +++ b/internal/web/templates/betreiber_account_neu.html @@ -11,19 +11,65 @@ Ergebnis wie die öffentliche Registrierung, nur vom Betreiber aus.
{{if .Error}}{{.Error}}
{{end}} +