diff --git a/backend/prisma/migrations/20260806110000_customer_vat_exempt/migration.sql b/backend/prisma/migrations/20260806110000_customer_vat_exempt/migration.sql new file mode 100644 index 00000000..8d2407d9 --- /dev/null +++ b/backend/prisma/migrations/20260806110000_customer_vat_exempt/migration.sql @@ -0,0 +1,3 @@ +-- Kleinunternehmer/USt-befreit-Flag am Kunden. Steuert nur die Vorbelegung +-- neuer Gutschriften (Firmenkunde mit vatExempt=true -> keine USt-Vorbelegung). +ALTER TABLE `Customer` ADD COLUMN IF NOT EXISTS `vatExempt` BOOLEAN NOT NULL DEFAULT false; diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index ce388e37..c3a83fb6 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -156,6 +156,12 @@ model Customer { phone String? mobile String? taxNumber String? + // Kleinunternehmer nach §19 UStG bzw. sonst USt-befreit: dann wird der + // (Firmen-)Kunde bei Gutschriften wie ein Privatkunde behandelt (keine USt), + // bis er ggf. USt-pflichtig wird. Steuert nur die VORBELEGUNG neuer + // Gutschriften; bereits erstellte Gutschriften speichern ihren eigenen + // Snapshot und bleiben von einem späteren Wechsel unberührt. + vatExempt Boolean @default(false) businessRegistrationPath String? // PDF-Pfad zur Gewerbeanmeldung commercialRegisterPath String? // PDF-Pfad zum Handelsregisterauszug commercialRegisterNumber String? // Handelsregisternummer (Text) diff --git a/backend/src/controllers/creditNote.controller.ts b/backend/src/controllers/creditNote.controller.ts index a3bfad68..8b935e1c 100644 --- a/backend/src/controllers/creditNote.controller.ts +++ b/backend/src/controllers/creditNote.controller.ts @@ -60,11 +60,11 @@ export async function formDefaults(req: AuthRequest, res: Response): Promise { +// Ermittelt die Formular-Vorbelegung aus dem Kunden: +// - customerType: Firma vs. Privat (aus Kunde.type) +// - vatRelevant : Default nur bei Firmenkunde OHNE USt-Befreiung +// (Kleinunternehmer §19 → wie Privat, keine USt-Vorbelegung). +// WICHTIG: Das ist nur die Vorbelegung. Jede angelegte Gutschrift speichert +// ihren eigenen Snapshot; ein späterer Statuswechsel des Kunden ändert +// bestehende Gutschriften nicht. +export async function getCreditNoteDefaults( + contractId: number, +): Promise<{ customerType: CreditNoteCustomerType; vatRelevant: boolean }> { const contract = await prisma.contract.findUnique({ where: { id: contractId }, - select: { customer: { select: { type: true } } }, + select: { customer: { select: { type: true, vatExempt: true } } }, }); - return contract?.customer?.type === 'BUSINESS' ? 'FIRMA' : 'PRIVAT'; + const isBusiness = contract?.customer?.type === 'BUSINESS'; + const vatExempt = !!contract?.customer?.vatExempt; + return { + customerType: isBusiness ? 'FIRMA' : 'PRIVAT', + vatRelevant: isBusiness && !vatExempt, + }; } export async function createCreditNote( diff --git a/backend/src/services/customer.service.ts b/backend/src/services/customer.service.ts index 91961b81..73bf7503 100644 --- a/backend/src/services/customer.service.ts +++ b/backend/src/services/customer.service.ts @@ -146,6 +146,7 @@ export async function createCustomer(data: { phone?: string; mobile?: string; taxNumber?: string; + vatExempt?: boolean; businessRegistration?: string; commercialRegister?: string; notes?: string; @@ -173,6 +174,7 @@ export async function updateCustomer( phone?: string; mobile?: string; taxNumber?: string; + vatExempt?: boolean; businessRegistration?: string; commercialRegister?: string; notes?: string; diff --git a/backend/src/utils/sanitize.ts b/backend/src/utils/sanitize.ts index 02e05e71..7ed34210 100644 --- a/backend/src/utils/sanitize.ts +++ b/backend/src/utils/sanitize.ts @@ -618,6 +618,7 @@ const CUSTOMER_UPDATABLE_FIELDS = [ 'phone', 'mobile', 'taxNumber', + 'vatExempt', 'commercialRegisterNumber', 'notes', 'portalEnabled', diff --git a/docs/todo.md b/docs/todo.md index a08e255e..8fe08cdf 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -116,7 +116,12 @@ isolierte Instanz (keine Multi-Tenancy im Code), Provisioning + Abrechnung `GET/PUT/DELETE /credit-notes/:id`, `GET/PUT /credit-notes/number-range`. Portal-Token explizit geblockt (interner Bereich), CREATE/UPDATE/DELETE auditiert. - - **Offen:** Phase 2 (Vertrag-UI + Beleg-Upload + Nummernkreis-UI), + - **Phase 2a (erledigt):** Kunden-Feld `vatExempt` (Kleinunternehmer §19, + Checkbox nur für Firmenkunden) + Migration. Gutschrift-Default `vatRelevant` + wird abgeleitet: Firmenkunde ohne USt-Befreiung → USt-relevant an, sonst + aus. Jede Gutschrift speichert ihren Snapshot → späterer Statuswechsel + ändert bestehende Gutschriften nicht. USt-Default „aus Kundentyp ableiten". + - **Offen:** Phase 2b (Vertrag-Tab „Gutschriften" + Beleg-Upload + Nummernkreis-UI), Phase 3 (PDF + ZUGFeRD/PDF-A3, muss gegen ZUGFeRD-Validator geprüft werden). USt-Einordnung (Vermittlung vs. Abschlussbonus) mit Steuerberater klären – Modell deckt beide über `vatRelevant` ab. diff --git a/frontend/src/pages/customers/CustomerForm.tsx b/frontend/src/pages/customers/CustomerForm.tsx index 6f8b7254..a8b078ff 100644 --- a/frontend/src/pages/customers/CustomerForm.tsx +++ b/frontend/src/pages/customers/CustomerForm.tsx @@ -77,6 +77,7 @@ export default function CustomerForm() { phone: data.phone || '', mobile: data.mobile || '', taxNumber: data.taxNumber || '', + vatExempt: data.vatExempt === true || (data.vatExempt as any) === 'true', commercialRegisterNumber: data.commercialRegisterNumber || '', notes: data.notes || '', birthPlace: data.birthPlace || '', @@ -229,6 +230,20 @@ export default function CustomerForm() { placeholder="z.B. HRB 12345" /> + {isEdit && (

Dokumente (Gewerbeanmeldung, Handelsregisterauszug) können nach dem Speichern in der Kundendetailansicht hochgeladen werden. diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index fb816812..a8944e62 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -109,6 +109,7 @@ export interface Customer { phone?: string; mobile?: string; taxNumber?: string; + vatExempt?: boolean; businessRegistrationPath?: string; commercialRegisterPath?: string; commercialRegisterNumber?: string;