feat: vollständige Firmendaten (Adresse, Abrechnung) bei Firmenanlage

Bei Firmenanlage (Registrierung + Betreiber-Firmenanlage) müssen jetzt
Adresse (Straße, PLZ, Ort, Land) und Abrechnungsdaten (Rechnungsemail,
optional USt-IdNr.) erfasst werden, nicht nur der Firmenname (Migration
0023). USt-IdNr. bewusst optional - Kleinunternehmer nach §19 UStG
haben keine. Neue Seite /verwaltung/firma (admin-only) zum Einsehen/
Nachtragen für bestehende Firmen. store.CreateAccount nimmt jetzt ein
AccountInput statt nur einen Namen entgegen (Signaturänderung betrifft
~20 Testaufrufe, mechanisch umgestellt). register.html/
betreiber_account_neu.html auf form-card/form-grid umgestellt.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
noroot
2026-09-01 11:36:05 +02:00
parent fa5e68c892
commit 7db4707bc5
24 changed files with 620 additions and 68 deletions

View File

@@ -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)

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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;

View File

@@ -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 '';

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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)
}

View File

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

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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)
}

View File

@@ -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))

View File

@@ -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)
}

View File

@@ -11,19 +11,65 @@
Ergebnis wie die öffentliche Registrierung, nur vom Betreiber aus.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<div class="form-card">
<form method="post" action="/betreiber/accounts">
<label for="account_name">Firmenname</label>
<input type="text" id="account_name" name="account_name" required>
<label for="email">E-Mail (erster Nutzer, wird Admin)</label>
<input type="email" id="email" name="email" required>
<label for="password">Initialpasswort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
<button type="submit">Firma anlegen</button>
<fieldset class="form-section">
<legend>Firma</legend>
<div class="form-grid">
<div class="form-full">
<label for="account_name">Firmenname</label>
<input type="text" id="account_name" name="account_name" required>
</div>
<div class="form-full">
<label for="strasse">Straße und Hausnummer</label>
<input type="text" id="strasse" name="strasse" required>
</div>
<div>
<label for="plz">PLZ</label>
<input type="text" id="plz" name="plz" required>
</div>
<div>
<label for="ort">Ort</label>
<input type="text" id="ort" name="ort" required>
</div>
<div>
<label for="land">Land</label>
<input type="text" id="land" name="land" value="Deutschland" required>
</div>
</div>
</fieldset>
<fieldset class="form-section">
<legend>Abrechnung</legend>
<div class="form-grid">
<div>
<label for="rechnungsemail">Rechnungsemail</label>
<input type="email" id="rechnungsemail" name="rechnungsemail" required>
</div>
<div>
<label for="ust_id">USt-IdNr. (optional)</label>
<input type="text" id="ust_id" name="ust_id" placeholder="z. B. DE123456789">
</div>
</div>
</fieldset>
<fieldset class="form-section">
<legend>Erster Nutzer (wird Admin)</legend>
<div class="form-grid">
<div>
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
</div>
<div>
<label for="password">Initialpasswort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit">Firma anlegen</button>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}

View File

@@ -0,0 +1,63 @@
{{define "firmendaten"}}<!doctype html>
<html lang="de">
<head>{{template "head" .}}</head>
<body>
{{template "nav" .Nav}}
<div class="page">
<div class="page-header">
<div>
<h1>Firmendaten</h1>
<p class="hinweis">Adress- und Abrechnungsdaten deiner Firma.</p>
</div>
</div>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
{{if .Gespeichert}}<p class="hinweis">Gespeichert.</p>{{end}}
<div class="form-card">
<form method="post" action="/verwaltung/firma">
<fieldset class="form-section">
<legend>Firma</legend>
<div class="form-grid">
<div class="form-full">
<label for="account_name">Firmenname</label>
<input type="text" id="account_name" name="account_name" value="{{.Name}}" required>
</div>
<div class="form-full">
<label for="strasse">Straße und Hausnummer</label>
<input type="text" id="strasse" name="strasse" value="{{.Strasse}}" required>
</div>
<div>
<label for="plz">PLZ</label>
<input type="text" id="plz" name="plz" value="{{.PLZ}}" required>
</div>
<div>
<label for="ort">Ort</label>
<input type="text" id="ort" name="ort" value="{{.Ort}}" required>
</div>
<div>
<label for="land">Land</label>
<input type="text" id="land" name="land" value="{{.Land}}" required>
</div>
</div>
</fieldset>
<fieldset class="form-section">
<legend>Abrechnung</legend>
<div class="form-grid">
<div>
<label for="rechnungsemail">Rechnungsemail</label>
<input type="email" id="rechnungsemail" name="rechnungsemail" value="{{.Rechnungsemail}}" required>
</div>
<div>
<label for="ust_id">USt-IdNr. (optional)</label>
<input type="text" id="ust_id" name="ust_id" value="{{.UStID}}" placeholder="z. B. DE123456789">
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit">Speichern</button>
</div>
</form>
</div>
</div>
</body>
</html>
{{end}}

View File

@@ -28,6 +28,7 @@
{{if .IsFachebene}}<a href="/faelle">Posteingang</a>{{end}}
{{if .IsFachebene}}<a href="/registereintraege">Register</a>{{end}}
{{if .IsFachebene}}<a href="/wiedervorlage">Wiedervorlage</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/firma">Firmendaten</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/nutzer">Nutzerverwaltung</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/einladung">Einladungslink</a>{{end}}
{{if .IsAdmin}}<a href="/verwaltung/abteilungen">Abteilungen</a>{{end}}

View File

@@ -9,18 +9,64 @@
den Betreiber freigeschaltet.
</p>
{{if .Error}}<p class="fehler">{{.Error}}</p>{{end}}
<div class="form-card">
<form method="post" action="/register">
<label for="account_name">Firmenname</label>
<input type="text" id="account_name" name="account_name" required>
<label for="email">E-Mail (erster Nutzer, wird Admin)</label>
<input type="email" id="email" name="email" required>
<label for="password">Passwort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
<button type="submit">Konto anlegen</button>
<fieldset class="form-section">
<legend>Firma</legend>
<div class="form-grid">
<div class="form-full">
<label for="account_name">Firmenname</label>
<input type="text" id="account_name" name="account_name" required>
</div>
<div class="form-full">
<label for="strasse">Straße und Hausnummer</label>
<input type="text" id="strasse" name="strasse" required>
</div>
<div>
<label for="plz">PLZ</label>
<input type="text" id="plz" name="plz" required>
</div>
<div>
<label for="ort">Ort</label>
<input type="text" id="ort" name="ort" required>
</div>
<div>
<label for="land">Land</label>
<input type="text" id="land" name="land" value="Deutschland" required>
</div>
</div>
</fieldset>
<fieldset class="form-section">
<legend>Abrechnung</legend>
<div class="form-grid">
<div>
<label for="rechnungsemail">Rechnungsemail</label>
<input type="email" id="rechnungsemail" name="rechnungsemail" required>
</div>
<div>
<label for="ust_id">USt-IdNr. (optional)</label>
<input type="text" id="ust_id" name="ust_id" placeholder="z. B. DE123456789">
</div>
</div>
</fieldset>
<fieldset class="form-section">
<legend>Erster Nutzer (wird Admin)</legend>
<div class="form-grid">
<div>
<label for="email">E-Mail</label>
<input type="email" id="email" name="email" required>
</div>
<div>
<label for="password">Passwort (mind. 8 Zeichen)</label>
<input type="password" id="password" name="password" minlength="8" required>
</div>
</div>
</fieldset>
<div class="form-actions">
<button type="submit">Konto anlegen</button>
</div>
</form>
</div>
<p><a href="/login">Schon ein Konto? Anmelden</a></p>
</div>
</body>