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:
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
20
internal/store/migrations/0023_account_firmendaten.up.sql
Normal file
20
internal/store/migrations/0023_account_firmendaten.up.sql
Normal 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 '';
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user