From 894a9436b0dae74144252bab352dc99a6ec1493b Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Fri, 12 Jun 2026 10:58:43 +0000 Subject: [PATCH] USt-Behandlung: Netto-Preise, VatResolver und Steuer-Ausweis im MAN-Kreis Einwand/Entscheidung 12.06.2026: Legacy fakturierte brutto (Steuer inkludiert, z. B. 199 Euro; steuerbefreite Kunden mit Netto-Ausweis 167,23). Alle neuen Preise sind netto; die Steuer wird zur Rechnungsstellung sauber validiert und ausgewiesen. - VatResolver + VatTreatment: DE grundsaetzlich immer mit Steuer, EU nur mit (formal plausibler) USt-ID befreit (Reverse Charge inkl. Pflichthinweis), Drittlaender grundsaetzlich befreit; EU-Laenderliste + vat_rate in config/billing.php - Schema: billing_addresses.vat_id + invoice_billing_addresses.vat_id (Snapshot pro Rechnung), invoices.tax_note; Profil-Formular schreibt die vorhandene USt-ID jetzt auch an die Rechnungsadresse - ManualInvoiceService: rechnet auf Netto-Vertragsbasis (legacy_conditions.net_cents bzw. Netto-Katalogpreis) und bestimmt Steuer/is_netto/tax_note pro Rechnung ueber den VatResolver - legacy:grandfather-subscriptions: leitet net_cents aus der letzten Legacy-Rechnung ab (brutto / 1,19 bzw. is_netto-Betrag direkt); fuer DE-Bestandskunden bleibt der Bruttobetrag unveraendert (199 brutto -> 167,23 netto + 31,77 USt = 199,00) - Doku: Decision-Update 2.1 (Netto-Klarstellung), Phase-9-Plan, Checkliste, 05-DATABASE-MERGE 5.6; offen: VIES-Validierung der USt-ID Tests: VatResolverTest (Datasets fuer alle Faelle), Reverse-Charge/ EU-/Drittland-Rechnungen, Netto-Ableitung; Suite 490 passed, 4 skipped. Pint clean. Co-Authored-By: Claude Fable 5 --- .../GrandfatherLegacySubscriptions.php | 34 ++++- app/Enums/VatTreatment.php | 40 ++++++ app/Models/BillingAddress.php | 1 + app/Models/Invoice.php | 1 + app/Models/InvoiceBillingAddress.php | 1 + app/Services/Billing/ManualInvoiceService.php | 49 ++++--- app/Services/Billing/VatResolver.php | 66 +++++++++ config/billing.php | 22 ++- config/cashier.php | 130 ++++++++++++++++++ config/services.php | 1 - ...6_12_105216_add_vat_fields_for_billing.php | 45 ++++++ dev/migration 2026/05-DATABASE-MERGE.md | 3 +- ... Preisstruktur & Veröffentlichungs-Flow.md | 18 +++ docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md | 10 ++ docs/user-admin/checkliste-user-backend.md | 4 +- .../views/livewire/customer/profile.blade.php | 4 + .../GrandfatherLegacySubscriptionsTest.php | 33 ++++- .../Billing/ManualInvoiceGenerationTest.php | 48 ++++++- tests/Feature/Billing/VatResolverTest.php | 33 +++++ 19 files changed, 497 insertions(+), 46 deletions(-) create mode 100644 app/Enums/VatTreatment.php create mode 100644 app/Services/Billing/VatResolver.php create mode 100644 config/cashier.php create mode 100644 database/migrations/2026_06_12_105216_add_vat_fields_for_billing.php create mode 100644 tests/Feature/Billing/VatResolverTest.php diff --git a/app/Console/Commands/GrandfatherLegacySubscriptions.php b/app/Console/Commands/GrandfatherLegacySubscriptions.php index 89e7e6d..dac9493 100644 --- a/app/Console/Commands/GrandfatherLegacySubscriptions.php +++ b/app/Console/Commands/GrandfatherLegacySubscriptions.php @@ -214,16 +214,35 @@ class GrandfatherLegacySubscriptions extends Command 'article_number' => $option['article_number'] ?? null, 'name' => $payload['payment_option_translation']['name'] ?? null, 'interval' => 'yearly', - 'amount_cents' => $invoice->amount_cents, - 'tax_cents' => $invoice->tax_cents, - 'total_cents' => $invoice->total_cents, - 'is_netto' => (bool) ($snapshot['is_netto'] ?? false), + 'net_cents' => $this->deriveNetCents($invoice), + 'last_total_cents' => $invoice->total_cents, + 'last_is_netto' => (bool) ($snapshot['is_netto'] ?? false), 'source_invoice_number' => $invoice->number, 'source_invoice_date' => $invoice->invoice_date->toDateString(), ], ]; } + /** + * Netto-Vertragsbasis aus der letzten Legacy-Rechnung. Legacy fakturierte + * brutto (Steuer inkludiert, z. B. 199,00 €); steuerbefreite Kunden + * erhielten den Netto-Ausweis (`is_netto`, z. B. 167,23 €). Die neue + * Rechnungsstellung arbeitet immer auf Netto-Basis — die Steuer wird + * pro Rechnung über den VatResolver bestimmt. + */ + private function deriveNetCents(LegacyInvoice $invoice): int + { + $isNetto = (bool) (($invoice->raw_snapshot ?? [])['is_netto'] ?? false); + + if ($isNetto) { + return $invoice->total_cents; + } + + $vatRate = (float) config('billing.vat_rate', 0.19); + + return (int) round($invoice->total_cents / (1 + $vatRate)); + } + /** * Versteckter Katalog-Eintrag pro (Portal, Legacy-Artikel) — die * verbindlichen Beträge pro Vereinbarung liegen in legacy_conditions. @@ -237,7 +256,8 @@ class GrandfatherLegacySubscriptions extends Command ['article_number' => $articleNumber], [ 'type' => 'recurring', - 'price_cents' => $candidate['legacy_conditions']['amount_cents'], + // Katalogpreise sind netto (Entscheidung 12.06.2026). + 'price_cents' => $candidate['legacy_conditions']['net_cents'], 'currency' => 'EUR', 'interval' => 'yearly', 'is_hidden' => true, @@ -261,12 +281,12 @@ class GrandfatherLegacySubscriptions extends Command private function describe(array $candidate): string { return sprintf( - 'User #%d · %s · Legacy-UPO #%d · fällig %s · %s €', + 'User #%d · %s · Legacy-UPO #%d · fällig %s · netto %s €', $candidate['user_id'], $candidate['legacy_portal'], $candidate['legacy_upo_id'], $candidate['next_due_date']->toDateString(), - number_format($candidate['legacy_conditions']['total_cents'] / 100, 2, ',', '.'), + number_format($candidate['legacy_conditions']['net_cents'] / 100, 2, ',', '.'), ); } } diff --git a/app/Enums/VatTreatment.php b/app/Enums/VatTreatment.php new file mode 100644 index 0000000..c0482eb --- /dev/null +++ b/app/Enums/VatTreatment.php @@ -0,0 +1,40 @@ + 'Inland (Deutschland)', + self::EuConsumer => 'EU ohne USt-ID', + self::ReverseCharge => 'EU mit USt-ID (Reverse Charge)', + self::ThirdCountry => 'Drittland (steuerbefreit)', + }; + } + + public function taxNote(): ?string + { + return match ($this) { + self::ReverseCharge => 'Steuerschuldnerschaft des Leistungsempfängers (Reverse Charge, Art. 196 MwStSystRL).', + self::ThirdCountry => 'Nicht im Inland steuerbare Leistung (§ 3a Abs. 2 UStG).', + default => null, + }; + } +} diff --git a/app/Models/BillingAddress.php b/app/Models/BillingAddress.php index d81b2da..d6bb159 100644 --- a/app/Models/BillingAddress.php +++ b/app/Models/BillingAddress.php @@ -20,6 +20,7 @@ class BillingAddress extends Model 'postal_code', 'city', 'country_code', + 'vat_id', ]; public function user(): BelongsTo diff --git a/app/Models/Invoice.php b/app/Models/Invoice.php index e34c078..8bcfb71 100644 --- a/app/Models/Invoice.php +++ b/app/Models/Invoice.php @@ -23,6 +23,7 @@ class Invoice extends Model 'total_cents', 'currency', 'is_netto', + 'tax_note', 'invoice_date', 'due_date', 'paid_at', diff --git a/app/Models/InvoiceBillingAddress.php b/app/Models/InvoiceBillingAddress.php index 3054856..2765976 100644 --- a/app/Models/InvoiceBillingAddress.php +++ b/app/Models/InvoiceBillingAddress.php @@ -19,6 +19,7 @@ class InvoiceBillingAddress extends Model 'postal_code', 'city', 'country_code', + 'vat_id', ]; public function invoices(): HasMany diff --git a/app/Services/Billing/ManualInvoiceService.php b/app/Services/Billing/ManualInvoiceService.php index 18c4da2..83980a0 100644 --- a/app/Services/Billing/ManualInvoiceService.php +++ b/app/Services/Billing/ManualInvoiceService.php @@ -22,14 +22,19 @@ use Illuminate\Support\Facades\Log; * weitergeschaltet. Neue Abschlüsse laufen ausschließlich über Stripe * (STR-Kreis) und werden hier bewusst ausgeklammert. * - * Konditions-Overrides pro Vereinbarung über `legacy_conditions` (JSON): - * `amount_cents`, `tax_cents`, `total_cents`, `interval` (monthly|yearly). - * Ohne Override gilt der Netto-Preis der `payment_option` plus - * `billing.vat_rate`. + * Preisbasis ist immer NETTO (Entscheidung 12.06.2026): `legacy_conditions. + * net_cents` (von der Grandfather-Migration aus den Brutto-/Netto-Beträgen + * der letzten Legacy-Rechnung abgeleitet), sonst der Netto-Preis der + * `payment_option`. Die Steuer wird zur Rechnungsstellung über den + * `VatResolver` bestimmt und sauber ausgewiesen (DE immer mit Steuer, + * EU nur mit gültiger USt-ID befreit, Drittland befreit). */ class ManualInvoiceService { - public function __construct(private readonly InvoiceNumberGenerator $numbers) {} + public function __construct( + private readonly InvoiceNumberGenerator $numbers, + private readonly VatResolver $vat, + ) {} /** * @return Collection @@ -87,9 +92,14 @@ class ManualInvoiceService return null; } - [$amountCents, $taxCents, $totalCents] = $this->resolveAmounts($option); + $netCents = $this->resolveNetCents($option); - return DB::transaction(function () use ($option, $user, $billingAddress, $amountCents, $taxCents, $totalCents, $interval, $asOf): Invoice { + // USt zur Rechnungsstellung bestimmen: DE immer mit Steuer, EU nur + // mit gültiger USt-ID befreit (Reverse Charge), Drittland befreit. + $treatment = $this->vat->resolve($billingAddress->country_code, $billingAddress->vat_id); + $taxCents = $this->vat->taxCentsFor($netCents, $treatment); + + return DB::transaction(function () use ($option, $user, $billingAddress, $netCents, $taxCents, $treatment, $interval, $asOf): Invoice { // Adresse pro Rechnung einfrieren (Snapshot-Tabelle). $addressSnapshot = InvoiceBillingAddress::query()->create([ 'salutation_key' => $billingAddress->salutation_key, @@ -100,6 +110,7 @@ class ManualInvoiceService 'postal_code' => $billingAddress->postal_code, 'city' => $billingAddress->city, 'country_code' => $billingAddress->country_code, + 'vat_id' => $billingAddress->vat_id, ]); $invoice = Invoice::query()->create([ @@ -107,10 +118,12 @@ class ManualInvoiceService 'invoice_billing_address_id' => $addressSnapshot->id, 'number' => $this->numbers->nextManualNumber(), 'status' => InvoiceStatus::Open->value, - 'amount_cents' => $amountCents, + 'amount_cents' => $netCents, 'tax_cents' => $taxCents, - 'total_cents' => $totalCents, + 'total_cents' => $netCents + $taxCents, 'currency' => $option->paymentOption?->currency ?? 'EUR', + 'is_netto' => $treatment->isTaxExempt(), + 'tax_note' => $treatment->taxNote(), 'invoice_date' => $asOf, 'due_date' => $asOf->copy()->addDays((int) config('billing.manual_due_days', 14)), ]); @@ -135,22 +148,14 @@ class ManualInvoiceService } /** - * @return array{0: int, 1: int, 2: int} [amount_cents, tax_cents, total_cents] + * Netto-Vertragsbasis der Vereinbarung. Alle neuen Preise sind netto; + * für Grandfathered-Vereinbarungen liefert die Migration `net_cents` + * (aus den Brutto-/Netto-Beträgen der letzten Legacy-Rechnung). */ - private function resolveAmounts(UserPaymentOption $option): array + private function resolveNetCents(UserPaymentOption $option): int { $conditions = $option->legacy_conditions ?? []; - if (isset($conditions['amount_cents'], $conditions['total_cents'])) { - $amount = (int) $conditions['amount_cents']; - $total = (int) $conditions['total_cents']; - - return [$amount, (int) ($conditions['tax_cents'] ?? $total - $amount), $total]; - } - - $amount = (int) ($conditions['amount_cents'] ?? $option->paymentOption?->price_cents ?? 0); - $tax = (int) round($amount * (float) config('billing.vat_rate', 0.19)); - - return [$amount, $tax, $amount + $tax]; + return (int) ($conditions['net_cents'] ?? $option->paymentOption?->price_cents ?? 0); } } diff --git a/app/Services/Billing/VatResolver.php b/app/Services/Billing/VatResolver.php new file mode 100644 index 0000000..17b447e --- /dev/null +++ b/app/Services/Billing/VatResolver.php @@ -0,0 +1,66 @@ +isPlausibleVatId($vatId, $countryCode) + ? VatTreatment::ReverseCharge + : VatTreatment::EuConsumer; + } + + public function rateFor(VatTreatment $treatment): float + { + return $treatment->isTaxExempt() ? 0.0 : (float) config('billing.vat_rate', 0.19); + } + + public function taxCentsFor(int $netCents, VatTreatment $treatment): int + { + return (int) round($netCents * $this->rateFor($treatment)); + } + + /** + * Formale Plausibilität: beginnt mit dem Ländercode der Adresse und + * trägt danach 2–13 alphanumerische Zeichen (EU-Formatrahmen). + */ + private function isPlausibleVatId(?string $vatId, string $countryCode): bool + { + $vatId = strtoupper(preg_replace('/\s+/', '', (string) $vatId) ?? ''); + + if ($vatId === '') { + return false; + } + + // Griechenland nutzt das Präfix EL statt GR. + $expectedPrefix = $countryCode === 'GR' ? 'EL' : $countryCode; + + return (bool) preg_match('/^'.preg_quote($expectedPrefix, '/').'[A-Z0-9]{2,13}$/', $vatId); + } +} diff --git a/config/billing.php b/config/billing.php index 2ec4616..e5dd715 100644 --- a/config/billing.php +++ b/config/billing.php @@ -34,8 +34,26 @@ return [ // Zahlungsziel für Rechnungen des manuellen Kreises (Tage). 'manual_due_days' => env('BILLING_MANUAL_DUE_DAYS', 14), - // MwSt-Satz für den manuellen Kreis, wenn die Vereinbarung keine - // expliziten Beträge in legacy_conditions mitbringt. + /* + |-------------------------------------------------------------------------- + | USt-Behandlung (Entscheidung 12.06.2026) + |-------------------------------------------------------------------------- + | + | Alle neuen Preise sind NETTO. Die Steuer wird zur Rechnungsstellung + | anhand der Rechnungsadresse bestimmt (VatResolver): Deutschland immer + | mit Steuer, EU-Ausland nur mit gültiger USt-ID befreit (Reverse + | Charge), Drittländer grundsätzlich befreit. + | + */ + 'vat_rate' => env('BILLING_VAT_RATE', 0.19), + // EU-Mitgliedstaaten (ISO 3166-1 alpha-2), Stand 2026 — ohne DE, + // das im VatResolver als Inland behandelt wird. + 'eu_country_codes' => [ + 'AT', 'BE', 'BG', 'CY', 'CZ', 'DK', 'EE', 'ES', 'FI', 'FR', + 'GR', 'HR', 'HU', 'IE', 'IT', 'LT', 'LU', 'LV', 'MT', 'NL', + 'PL', 'PT', 'RO', 'SE', 'SI', 'SK', + ], + ]; diff --git a/config/cashier.php b/config/cashier.php new file mode 100644 index 0000000..574e2ca --- /dev/null +++ b/config/cashier.php @@ -0,0 +1,130 @@ + env('STRIPE_KEY'), + + 'secret' => env('STRIPE_SECRET'), + + /* + |-------------------------------------------------------------------------- + | Cashier Path + |-------------------------------------------------------------------------- + | + | This is the base URI path where Cashier's views, such as the payment + | verification screen, will be available from. You're free to tweak + | this path according to your preferences and application design. + | + */ + + 'path' => env('CASHIER_PATH', 'stripe'), + + /* + |-------------------------------------------------------------------------- + | Stripe Webhooks + |-------------------------------------------------------------------------- + | + | Your Stripe webhook secret is used to prevent unauthorized requests to + | your Stripe webhook handling controllers. The tolerance setting will + | check the drift between the current time and the signed request's. + | + */ + + 'webhook' => [ + 'secret' => env('STRIPE_WEBHOOK_SECRET'), + 'tolerance' => env('STRIPE_WEBHOOK_TOLERANCE', 300), + 'events' => WebhookCommand::DEFAULT_EVENTS, + ], + + /* + |-------------------------------------------------------------------------- + | Currency + |-------------------------------------------------------------------------- + | + | This is the default currency that will be used when generating charges + | from your application. Of course, you are welcome to use any of the + | various world currencies that are currently supported via Stripe. + | + */ + + 'currency' => env('CASHIER_CURRENCY', 'usd'), + + /* + |-------------------------------------------------------------------------- + | Currency Locale + |-------------------------------------------------------------------------- + | + | This is the default locale in which your money values are formatted in + | for display. To utilize other locales besides the default en locale + | verify you have the "intl" PHP extension installed on the system. + | + */ + + 'currency_locale' => env('CASHIER_CURRENCY_LOCALE', 'en'), + + /* + |-------------------------------------------------------------------------- + | Payment Confirmation Notification + |-------------------------------------------------------------------------- + | + | If this setting is enabled, Cashier will automatically notify customers + | whose payments require additional verification. You should listen to + | Stripe's webhooks in order for this feature to function correctly. + | + */ + + 'payment_notification' => env('CASHIER_PAYMENT_NOTIFICATION'), + + /* + |-------------------------------------------------------------------------- + | Invoice Settings + |-------------------------------------------------------------------------- + | + | The following options determine how Cashier invoices are converted from + | HTML into PDFs. You're free to change the options based on the needs + | of your application or your preferences regarding invoice styling. + | + */ + + 'invoices' => [ + // Supported: DompdfInvoiceRenderer::class, LaravelPdfInvoiceRenderer::class + 'renderer' => env('CASHIER_INVOICE_RENDERER', DompdfInvoiceRenderer::class), + + 'options' => [ + // Supported: 'letter', 'legal', 'A4' + 'paper' => env('CASHIER_PAPER', 'letter'), + + 'remote_enabled' => env('CASHIER_REMOTE_ENABLED', false), + ], + ], + + /* + |-------------------------------------------------------------------------- + | Stripe Logger + |-------------------------------------------------------------------------- + | + | This setting defines which logging channel will be used by the Stripe + | library to write log messages. You are free to specify any of your + | logging channels listed inside the "logging" configuration file. + | + */ + + 'logger' => env('CASHIER_LOGGER'), + +]; diff --git a/config/services.php b/config/services.php index 58fff24..3b354e8 100644 --- a/config/services.php +++ b/config/services.php @@ -40,5 +40,4 @@ return [ 'model' => env('OPENAI_MODEL', 'gpt-5.4-mini'), 'timeout' => env('OPENAI_TIMEOUT', 60), ], - ]; diff --git a/database/migrations/2026_06_12_105216_add_vat_fields_for_billing.php b/database/migrations/2026_06_12_105216_add_vat_fields_for_billing.php new file mode 100644 index 0000000..e5a0799 --- /dev/null +++ b/database/migrations/2026_06_12_105216_add_vat_fields_for_billing.php @@ -0,0 +1,45 @@ +string('vat_id', 20)->nullable()->after('country_code'); + }); + + Schema::table('invoice_billing_addresses', function (Blueprint $table) { + $table->string('vat_id', 20)->nullable()->after('country_code'); + }); + + Schema::table('invoices', function (Blueprint $table) { + $table->string('tax_note', 191)->nullable()->after('is_netto'); + }); + } + + public function down(): void + { + Schema::table('billing_addresses', function (Blueprint $table) { + $table->dropColumn('vat_id'); + }); + + Schema::table('invoice_billing_addresses', function (Blueprint $table) { + $table->dropColumn('vat_id'); + }); + + Schema::table('invoices', function (Blueprint $table) { + $table->dropColumn('tax_note'); + }); + } +}; diff --git a/dev/migration 2026/05-DATABASE-MERGE.md b/dev/migration 2026/05-DATABASE-MERGE.md index 755038a..185946a 100644 --- a/dev/migration 2026/05-DATABASE-MERGE.md +++ b/dev/migration 2026/05-DATABASE-MERGE.md @@ -223,7 +223,8 @@ Vor dem Go-Live-Rehearsal muss der Report gegen den aktuellen Produktiv-Snapshot - **Aktiv-Regel** (aus dem Archiv abgeleitet): jüngste Rechnung pro (Portal, Legacy-`user_payment_option`) mit `pdf_payload.payment_option.type = 'recurring'` und `pdf_payload.user_payment_option.status = 'active'`; `next_due_date` darf höchstens `--grace-months` (Default 12) überfällig sein, sonst gilt die Vereinbarung als stale und bleibt reines Archiv. Einmal-Käufe (`type = single`) werden nie übernommen. - **Übernahme** als `grandfathered` in `user_payment_options`: - `status = 'grandfathered'`, `grandfathered_until = legacy.valid_until_date` (nullable), `stripe_subscription_id = null`. - - `current_period_start = service_period_begin_date` der jüngsten Rechnung, `current_period_end = next_due_date` → der tägliche MAN-Kreis-Lauf (`billing:generate-manual-invoices`) stellt die nächste Rechnung zum gewohnten (jährlichen) Rhythmus aus, mit den Beträgen der letzten Legacy-Rechnung (`legacy_conditions.amount/tax/total_cents`). + - `current_period_start = service_period_begin_date` der jüngsten Rechnung, `current_period_end = next_due_date` → der tägliche MAN-Kreis-Lauf (`billing:generate-manual-invoices`) stellt die nächste Rechnung zum gewohnten (jährlichen) Rhythmus aus. + - **Beträge (Klarstellung 12.06.):** Legacy fakturierte **brutto** (Steuer inkludiert); steuerbefreite Kunden erhielten den Netto-Ausweis (`is_netto`). Die Migration leitet daraus die **Netto-Vertragsbasis** ab (`legacy_conditions.net_cents`; brutto ÷ 1,19 bzw. Netto-Betrag direkt). Die Steuer bestimmt der `VatResolver` pro Rechnung neu: DE immer mit Steuer, EU nur mit gültiger USt-ID befreit (Reverse Charge), Drittland befreit — für deutsche Bestandskunden bleibt der Bruttobetrag unverändert, die Steuer wird künftig sauber ausgewiesen (`invoices.tax_note` bei Befreiung). - **Kein** Stripe-Subscription-Versuch (kein automatischer Import alter Abos in Stripe). Neue manuelle Rechnungen entstehen im **MAN-Rechnungskreis** (`invoices`), nie im Archiv. - Scheduler `ExpireGrandfatheredSubscriptions` (Customer-Benachrichtigung am `grandfathered_until`) bleibt offen — folgt mit dem Stripe-Billing-Block. - Alle historischen `user_payments` werden als Information ins Archiv geschrieben (analog `legacy_invoices` – optional). diff --git a/docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md b/docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md index a57b534..aecd4f7 100644 --- a/docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md +++ b/docs/Decision-Update Preisstruktur & Veröffentlichungs-Flow.md @@ -30,6 +30,24 @@ Dieses Update bündelt die in der Abstimmung getroffenen Entscheidungen zur Prei **Einzel-PM:** 19 € einmalig – geführt als **separater No-Abo-Block** neben dem Tarif-Raster, nicht als linke/billigste Spalte. Kommunikation über das No-Commitment-Argument („Einmal veröffentlichen, kein Abo, keine Kündigung"), nicht über den Preis. +### 2.1 Klarstellung Preise & Steuern (Einwand 12.06.2026) + +**Alle neuen Preise sind Netto-Preise.** Die Umsatzsteuer wird zur +Rechnungsstellung anhand der Rechnungsadresse bestimmt und sauber +ausgewiesen: + +- **Deutschland** → grundsätzlich immer mit Steuer (aktuell 19 %). +- **EU-Ausland** → nur mit gültiger USt-ID steuerbefreit (Reverse Charge), + sonst mit Steuer. +- **Drittländer** → grundsätzlich steuerbefreit. + +Zum Vergleich Legacy: Dort waren die Beträge **brutto** (z. B. 199 € inkl. +Steuer); steuerbefreite Kunden erhielten den Netto-Ausweis (167,23 €). +Grandfathered-Vereinbarungen werden deshalb auf die Netto-Basis der letzten +Legacy-Rechnung umgerechnet — für deutsche Bestandskunden bleibt der +Bruttobetrag damit unverändert, die Steuer wird künftig nur sauber +ausgewiesen. + **Enterprise:** sichtbar, aber als **dezenter Sales-Hinweis unterhalb der Tabelle** („Größere Mengen oder mehrere Marken? → Kontakt"). Keine eigene Preisspalte, individuelles Pricing. --- diff --git a/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md b/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md index 13f2bb5..a3caa2c 100644 --- a/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md +++ b/docs/PHASE-9-FLOW-UND-TARIFE-PLAN.md @@ -164,6 +164,16 @@ ist hybrid mit zwei getrennten Rechnungskreisen (plus Altbestand): - **`User::hasActiveBooking()`** prüft jetzt echt (hinter `billing.enforce_booking`): Cashier-Abo ∨ bezahlter Einzel-/Extra-PM-Kauf ∨ aktive/grandfathered Legacy-Vereinbarung (MAN-Kreis). +- **USt-Behandlung (Einwand 12.06.)**: Alle neuen Preise sind **netto**. + `VatResolver` bestimmt die Steuer pro Rechnung aus der Rechnungsadresse: + DE immer mit Steuer, EU nur mit (formal plausibler) USt-ID befreit + (Reverse Charge inkl. Pflichthinweis in `invoices.tax_note`), Drittland + befreit. `vat_id` an `billing_addresses` + Snapshot, gepflegt über das + bestehende USt-ID-Feld im Profil. Grandfathered-Vereinbarungen rechnen + auf der Netto-Basis der letzten Legacy-Rechnung (`net_cents`, brutto ÷ + 1,19 bzw. Netto-Ausweis direkt). **Offen**: echte VIES-Validierung der + USt-ID (aktuell Formatprüfung) — Folgeschritt, vor Gate-Aktivierung + empfohlen. - **Legacy-Migration (12.06.)**: `legacy:grandfather-subscriptions` leitet die aktiven, jährlich wiederkehrenden Vereinbarungen aus dem Rechnungsarchiv ab und schreibt sie als `grandfathered` in diff --git a/docs/user-admin/checkliste-user-backend.md b/docs/user-admin/checkliste-user-backend.md index ab34496..228b29b 100644 --- a/docs/user-admin/checkliste-user-backend.md +++ b/docs/user-admin/checkliste-user-backend.md @@ -126,7 +126,9 @@ Verbindliche Entscheidungen: `docs/Decision-Update Preisstruktur & Veröffentlic - [x] Hybride Rechnungskreise (Entscheidung 12.06.): fortlaufende Nummern via `InvoiceNumberGenerator` — **STR-** fuer den neuen Stripe-Shop, **MAN-** fuer laufende Legacy-Zahlungen; Alt-Archiv (`legacy_invoices`) bleibt unveraendert. - [x] MAN-Faelligkeitslauf: `billing:generate-manual-invoices` (taeglich 04:30) prueft `user_payment_options` ohne Stripe-Subscription auf erreichtes Periodenende, stellt Rechnung mit Adress-Snapshot aus und schaltet die Periode weiter (Konditions-Overrides via `legacy_conditions`). - [x] Aktive Legacy-Zahlungen migrieren (12.06.): `legacy:grandfather-subscriptions` leitet aus dem Rechnungsarchiv die aktiven jaehrlichen Vereinbarungen ab (22 im Test-Snapshot) und schreibt sie als `grandfathered` nach `user_payment_options` — Replay-faehig fuer den Lauf kurz vor Relaunch. Doku: `dev/migration 2026/05-DATABASE-MERGE.md` §5.6. -- [ ] Stripe-Checkout + Webhooks (Phase 9E): Produkte/Preise anlegen, STR-Rechnungsspiegelung, Slot-Logik auf Plan-Kontingent umstellen, Quota-Stub abloesen. Benoetigt `STRIPE_KEY`/`STRIPE_SECRET` in `.env`. +- [x] USt-Behandlung (12.06.): alle neuen Preise netto; `VatResolver` (DE immer Steuer, EU nur mit USt-ID befreit/Reverse Charge, Drittland befreit), `vat_id` an Rechnungsadresse + Rechnungs-Snapshot, `tax_note` auf Rechnungen; Grandfathered rechnen auf Netto-Basis der letzten Legacy-Rechnung (Brutto bleibt fuer DE-Bestandskunden gleich). +- [ ] VIES-Validierung der USt-ID (aktuell Formatpruefung) — vor Gate-/Checkout-Aktivierung. +- [ ] Stripe-Checkout + Webhooks (Phase 9E): Produkte/Preise anlegen (netto, Steuer via Stripe Tax oder VatResolver), STR-Rechnungsspiegelung, Slot-Logik auf Plan-Kontingent umstellen, Quota-Stub abloesen. Benoetigt `STRIPE_KEY`/`STRIPE_SECRET` in `.env`. - [ ] Tageslimit je Tier (Business 2 / Pro 3 / Agency 5), gilt auch fuer Extra-PMs. - [ ] Launch-Credits: Extra-PM, Boost (nur gruene PMs), Veroeffentlichungsnachweis-PDF; Credit-Anker 1 Credit = 1 €. - [ ] Einzel→Abo-Bruecke (19 € Anrechnung innerhalb 30 Tagen). diff --git a/resources/views/livewire/customer/profile.blade.php b/resources/views/livewire/customer/profile.blade.php index 62cac42..f2dc1d7 100644 --- a/resources/views/livewire/customer/profile.blade.php +++ b/resources/views/livewire/customer/profile.blade.php @@ -143,6 +143,10 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp 'postal_code' => $validated['billingPostalCode'], 'city' => $validated['billingCity'], 'country_code' => $validated['billingCountryCode'], + // USt-ID auch an der Rechnungsadresse pflegen — sie wird + // pro Rechnung eingefroren und bestimmt die Steuer + // (EU-Befreiung nur mit gültiger USt-ID). + 'vat_id' => $validated['taxIdNumber'] ?: null, ], ); } diff --git a/tests/Feature/Billing/GrandfatherLegacySubscriptionsTest.php b/tests/Feature/Billing/GrandfatherLegacySubscriptionsTest.php index 1b09977..e6b0402 100644 --- a/tests/Feature/Billing/GrandfatherLegacySubscriptionsTest.php +++ b/tests/Feature/Billing/GrandfatherLegacySubscriptionsTest.php @@ -73,7 +73,9 @@ test('an active recurring legacy agreement is migrated as grandfathered with the expect($agreement->current_period_start->toDateString())->toBe('2025-08-01'); expect($agreement->current_period_end->toDateString())->toBe('2026-08-01'); expect($agreement->legacy_conditions['interval'])->toBe('yearly'); - expect($agreement->legacy_conditions['total_cents'])->toBe(4900); + // Legacy fakturierte brutto: 49,00 € inkl. USt → Netto-Basis 41,18 €. + expect($agreement->legacy_conditions['net_cents'])->toBe(4118); + expect($agreement->legacy_conditions['last_total_cents'])->toBe(4900); expect($agreement->legacy_conditions['legacy_user_payment_option_id'])->toBe(42); $catalog = PaymentOption::query()->where('article_number', 'LEGACY-PE-PK-01')->sole(); @@ -111,7 +113,20 @@ test('the latest invoice per agreement wins', function () { $agreement = UserPaymentOption::sole(); expect($agreement->current_period_end->toDateString())->toBe('2026-08-01'); - expect($agreement->legacy_conditions['total_cents'])->toBe(4900); + expect($agreement->legacy_conditions['last_total_cents'])->toBe(4900); +}); + +test('a legacy net invoice keeps its amount as the net base', function () { + $user = User::factory()->create(); + legacyArchiveInvoice($user, [ + 'amount_cents' => 16723, + 'total_cents' => 16723, + 'raw_snapshot' => ['is_netto' => true], + ]); + + $this->artisan(GrandfatherLegacySubscriptions::class, ['--no-report' => true])->assertSuccessful(); + + expect(UserPaymentOption::sole()->legacy_conditions['net_cents'])->toBe(16723); }); test('re-running updates the agreement instead of duplicating it (pre-relaunch replay)', function () { @@ -156,11 +171,12 @@ test('dry-run writes nothing', function () { expect(PaymentOption::query()->where('article_number', 'like', 'LEGACY-%')->count())->toBe(0); }); -test('after migration the MAN circle invoices a due legacy agreement with the legacy amounts', function () { +test('after migration the MAN circle invoices a due legacy agreement with proper VAT', function () { $user = User::factory()->create(); - BillingAddress::factory()->create(['user_id' => $user->id]); + BillingAddress::factory()->create(['user_id' => $user->id, 'country_code' => 'DE']); - // Fällig: next_due_date liegt vor dem Stichtag (überfälliger Jahreskunde). + // Fällig: next_due_date liegt vor dem Stichtag (überfälliger Jahreskunde, + // Legacy-Brutto 199,00 €). legacyArchiveInvoice($user, [ 'invoice_date' => '2025-05-14', 'amount_cents' => 19900, @@ -178,10 +194,13 @@ test('after migration the MAN circle invoices a due legacy agreement with the le $invoice = $service->invoiceFor($due->first()); + // DE-Kunde: Netto 167,23 € + 19 % USt = 199,00 € — Brutto bleibt wie im + // Legacy, die Steuer wird jetzt aber sauber ausgewiesen. expect($invoice->number)->toBe('MAN-00001'); - expect($invoice->amount_cents)->toBe(19900); - expect($invoice->tax_cents)->toBe(0); + expect($invoice->amount_cents)->toBe(16723); + expect($invoice->tax_cents)->toBe(3177); expect($invoice->total_cents)->toBe(19900); + expect($invoice->is_netto)->toBeFalse(); // Periode jährlich weitergeschaltet. expect($due->first()->fresh()->current_period_end->toDateString())->toBe('2027-05-14'); diff --git a/tests/Feature/Billing/ManualInvoiceGenerationTest.php b/tests/Feature/Billing/ManualInvoiceGenerationTest.php index 7528504..5513ea5 100644 --- a/tests/Feature/Billing/ManualInvoiceGenerationTest.php +++ b/tests/Feature/Billing/ManualInvoiceGenerationTest.php @@ -34,7 +34,7 @@ function manualAgreement(array $overrides = [], array $optionOverrides = []): Us ]); } -test('a due manual agreement gets a MAN invoice and the period advances', function () { +test('a due manual agreement gets a MAN invoice with German VAT and the period advances', function () { Carbon::setTestNow('2026-06-12 08:00:00'); $agreement = manualAgreement([ @@ -46,9 +46,12 @@ test('a due manual agreement gets a MAN invoice and the period advances', functi expect($invoice)->not->toBeNull(); expect($invoice->number)->toBe('MAN-00001'); expect($invoice->status)->toBe(InvoiceStatus::Open); + // Netto-Preisbasis 49,00 € + 19 % USt (Adresse DE). expect($invoice->amount_cents)->toBe(4900); expect($invoice->tax_cents)->toBe(931); expect($invoice->total_cents)->toBe(5831); + expect($invoice->is_netto)->toBeFalse(); + expect($invoice->tax_note)->toBeNull(); expect($invoice->due_date->toDateString())->toBe('2026-06-26'); $fresh = $agreement->fresh(); @@ -58,15 +61,13 @@ test('a due manual agreement gets a MAN invoice and the period advances', functi Carbon::setTestNow(); }); -test('legacy_conditions override amounts and interval', function () { +test('legacy_conditions provide the net base and interval', function () { Carbon::setTestNow('2026-06-12 08:00:00'); $agreement = manualAgreement([ 'current_period_end' => '2026-06-10', 'legacy_conditions' => [ - 'amount_cents' => 10000, - 'tax_cents' => 1900, - 'total_cents' => 11900, + 'net_cents' => 10000, 'interval' => 'yearly', ], ]); @@ -81,6 +82,43 @@ test('legacy_conditions override amounts and interval', function () { Carbon::setTestNow(); }); +test('an EU agreement with a valid vat id is invoiced tax-free as reverse charge', function () { + $agreement = manualAgreement(); + $agreement->user->billingAddress->update(['country_code' => 'AT', 'vat_id' => 'ATU12345678']); + + $invoice = app(ManualInvoiceService::class)->invoiceFor($agreement); + + expect($invoice->amount_cents)->toBe(4900); + expect($invoice->tax_cents)->toBe(0); + expect($invoice->total_cents)->toBe(4900); + expect($invoice->is_netto)->toBeTrue(); + expect($invoice->tax_note)->toContain('Reverse Charge'); + expect($invoice->invoiceBillingAddress->vat_id)->toBe('ATU12345678'); +}); + +test('an EU agreement without a vat id is invoiced with German VAT', function () { + $agreement = manualAgreement(); + $agreement->user->billingAddress->update(['country_code' => 'AT', 'vat_id' => null]); + + $invoice = app(ManualInvoiceService::class)->invoiceFor($agreement); + + expect($invoice->tax_cents)->toBe(931); + expect($invoice->total_cents)->toBe(5831); + expect($invoice->is_netto)->toBeFalse(); +}); + +test('a third-country agreement is invoiced tax-free', function () { + $agreement = manualAgreement(); + $agreement->user->billingAddress->update(['country_code' => 'CH', 'vat_id' => null]); + + $invoice = app(ManualInvoiceService::class)->invoiceFor($agreement); + + expect($invoice->tax_cents)->toBe(0); + expect($invoice->total_cents)->toBe(4900); + expect($invoice->is_netto)->toBeTrue(); + expect($invoice->tax_note)->toContain('Nicht im Inland steuerbar'); +}); + test('the invoice freezes the billing address as a snapshot', function () { $agreement = manualAgreement(); $agreement->user->billingAddress->update(['name' => 'Alpha GmbH', 'city' => 'Berlin']); diff --git a/tests/Feature/Billing/VatResolverTest.php b/tests/Feature/Billing/VatResolverTest.php new file mode 100644 index 0000000..a977885 --- /dev/null +++ b/tests/Feature/Billing/VatResolverTest.php @@ -0,0 +1,33 @@ +resolve($country, $vatId))->toBe($expected); +})->with([ + 'Deutschland immer mit Steuer' => ['DE', null, VatTreatment::Domestic], + 'Deutschland auch mit USt-ID mit Steuer' => ['DE', 'DE123456789', VatTreatment::Domestic], + 'EU mit gültiger USt-ID → Reverse Charge' => ['AT', 'ATU12345678', VatTreatment::ReverseCharge], + 'EU ohne USt-ID → mit Steuer' => ['AT', null, VatTreatment::EuConsumer], + 'EU mit fremdländischer USt-ID → mit Steuer' => ['AT', 'DE123456789', VatTreatment::EuConsumer], + 'Griechenland mit EL-Präfix' => ['GR', 'EL123456789', VatTreatment::ReverseCharge], + 'Drittland grundsätzlich befreit' => ['CH', null, VatTreatment::ThirdCountry], + 'Drittland auch mit ID befreit' => ['US', 'US-TAX-1', VatTreatment::ThirdCountry], + 'Fehlendes Land wie Inland behandeln' => [null, null, VatTreatment::Domestic], +]); + +test('tax cents are derived from the treatment', function () { + $resolver = app(VatResolver::class); + + expect($resolver->taxCentsFor(16723, VatTreatment::Domestic))->toBe(3177); + expect($resolver->taxCentsFor(16723, VatTreatment::EuConsumer))->toBe(3177); + expect($resolver->taxCentsFor(16723, VatTreatment::ReverseCharge))->toBe(0); + expect($resolver->taxCentsFor(16723, VatTreatment::ThirdCountry))->toBe(0); +}); + +test('exempt treatments carry a legal tax note', function () { + expect(VatTreatment::ReverseCharge->taxNote())->toContain('Reverse Charge'); + expect(VatTreatment::ThirdCountry->taxNote())->toContain('Nicht im Inland steuerbar'); + expect(VatTreatment::Domestic->taxNote())->toBeNull(); +});