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 <noreply@anthropic.com>
This commit is contained in:
Kevin Adametz 2026-06-12 10:58:43 +00:00
parent 1cd4d8e33a
commit 894a9436b0
19 changed files with 497 additions and 46 deletions

View file

@ -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, ',', '.'),
);
}
}

View file

@ -0,0 +1,40 @@
<?php
namespace App\Enums;
/**
* USt-Behandlung einer Rechnung (Entscheidung 12.06.2026):
* Deutschland grundsätzlich immer mit Steuer, EU-Ausland nur mit gültiger
* USt-ID befreit (Reverse Charge), Drittländer grundsätzlich befreit.
*/
enum VatTreatment: string
{
case Domestic = 'domestic';
case EuConsumer = 'eu_consumer';
case ReverseCharge = 'reverse_charge';
case ThirdCountry = 'third_country';
public function isTaxExempt(): bool
{
return in_array($this, [self::ReverseCharge, self::ThirdCountry], true);
}
public function label(): string
{
return match ($this) {
self::Domestic => '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,
};
}
}

View file

@ -20,6 +20,7 @@ class BillingAddress extends Model
'postal_code',
'city',
'country_code',
'vat_id',
];
public function user(): BelongsTo

View file

@ -23,6 +23,7 @@ class Invoice extends Model
'total_cents',
'currency',
'is_netto',
'tax_note',
'invoice_date',
'due_date',
'paid_at',

View file

@ -19,6 +19,7 @@ class InvoiceBillingAddress extends Model
'postal_code',
'city',
'country_code',
'vat_id',
];
public function invoices(): HasMany

View file

@ -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<int, UserPaymentOption>
@ -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);
}
}

View file

@ -0,0 +1,66 @@
<?php
namespace App\Services\Billing;
use App\Enums\VatTreatment;
/**
* Bestimmt die USt-Behandlung einer Rechnung anhand der Rechnungsadresse
* (Entscheidung 12.06.2026):
*
* - Deutschland grundsätzlich immer mit Steuer (`billing.vat_rate`)
* - EU-Ausland nur mit gültiger USt-ID befreit (Reverse Charge),
* sonst mit Steuer
* - Drittland grundsätzlich steuerbefreit
*
* „Gültig" heißt aktuell: USt-ID vorhanden und formal plausibel
* (Ländercode + Kennziffer). Eine echte VIES-Validierung ist als
* Folgeschritt vorgesehen (siehe Phase-9-Plan).
*/
class VatResolver
{
public function resolve(?string $countryCode, ?string $vatId = null): VatTreatment
{
$countryCode = strtoupper((string) $countryCode);
if ($countryCode === '' || $countryCode === 'DE') {
return VatTreatment::Domestic;
}
if (! in_array($countryCode, (array) config('billing.eu_country_codes', []), true)) {
return VatTreatment::ThirdCountry;
}
return $this->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 213 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);
}
}

View file

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

130
config/cashier.php Normal file
View file

@ -0,0 +1,130 @@
<?php
use Laravel\Cashier\Console\WebhookCommand;
use Laravel\Cashier\Invoices\DompdfInvoiceRenderer;
// use Laravel\Cashier\Invoices\LaravelPdfInvoiceRenderer;
return [
/*
|--------------------------------------------------------------------------
| Stripe Keys
|--------------------------------------------------------------------------
|
| The Stripe publishable key and secret key give you access to Stripe's
| API. The "publishable" key is typically used when interacting with
| Stripe.js while the "secret" key accesses private API endpoints.
|
*/
'key' => 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'),
];

View file

@ -40,5 +40,4 @@ return [
'model' => env('OPENAI_MODEL', 'gpt-5.4-mini'),
'timeout' => env('OPENAI_TIMEOUT', 60),
],
];

View file

@ -0,0 +1,45 @@
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
return new class extends Migration
{
/**
* USt-Behandlung für neue Rechnungen (Entscheidung 12.06.2026):
* Deutschland immer mit Steuer, EU nur mit gültiger USt-ID befreit
* (Reverse Charge), Drittländer grundsätzlich befreit. Dafür braucht
* die Rechnungsadresse eine USt-ID (inkl. Snapshot pro Rechnung) und
* die Rechnung einen Steuerhinweis-Text.
*/
public function up(): void
{
Schema::table('billing_addresses', function (Blueprint $table) {
$table->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');
});
}
};

View file

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

View file

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

View file

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

View file

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

View file

@ -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,
],
);
}

View file

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

View file

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

View file

@ -0,0 +1,33 @@
<?php
use App\Enums\VatTreatment;
use App\Services\Billing\VatResolver;
test('vat treatment follows the 12.06. decision rules', function (?string $country, ?string $vatId, VatTreatment $expected) {
expect(app(VatResolver::class)->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();
});