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