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:
parent
1cd4d8e33a
commit
894a9436b0
19 changed files with 497 additions and 46 deletions
|
|
@ -214,16 +214,35 @@ class GrandfatherLegacySubscriptions extends Command
|
||||||
'article_number' => $option['article_number'] ?? null,
|
'article_number' => $option['article_number'] ?? null,
|
||||||
'name' => $payload['payment_option_translation']['name'] ?? null,
|
'name' => $payload['payment_option_translation']['name'] ?? null,
|
||||||
'interval' => 'yearly',
|
'interval' => 'yearly',
|
||||||
'amount_cents' => $invoice->amount_cents,
|
'net_cents' => $this->deriveNetCents($invoice),
|
||||||
'tax_cents' => $invoice->tax_cents,
|
'last_total_cents' => $invoice->total_cents,
|
||||||
'total_cents' => $invoice->total_cents,
|
'last_is_netto' => (bool) ($snapshot['is_netto'] ?? false),
|
||||||
'is_netto' => (bool) ($snapshot['is_netto'] ?? false),
|
|
||||||
'source_invoice_number' => $invoice->number,
|
'source_invoice_number' => $invoice->number,
|
||||||
'source_invoice_date' => $invoice->invoice_date->toDateString(),
|
'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
|
* Versteckter Katalog-Eintrag pro (Portal, Legacy-Artikel) — die
|
||||||
* verbindlichen Beträge pro Vereinbarung liegen in legacy_conditions.
|
* verbindlichen Beträge pro Vereinbarung liegen in legacy_conditions.
|
||||||
|
|
@ -237,7 +256,8 @@ class GrandfatherLegacySubscriptions extends Command
|
||||||
['article_number' => $articleNumber],
|
['article_number' => $articleNumber],
|
||||||
[
|
[
|
||||||
'type' => 'recurring',
|
'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',
|
'currency' => 'EUR',
|
||||||
'interval' => 'yearly',
|
'interval' => 'yearly',
|
||||||
'is_hidden' => true,
|
'is_hidden' => true,
|
||||||
|
|
@ -261,12 +281,12 @@ class GrandfatherLegacySubscriptions extends Command
|
||||||
private function describe(array $candidate): string
|
private function describe(array $candidate): string
|
||||||
{
|
{
|
||||||
return sprintf(
|
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['user_id'],
|
||||||
$candidate['legacy_portal'],
|
$candidate['legacy_portal'],
|
||||||
$candidate['legacy_upo_id'],
|
$candidate['legacy_upo_id'],
|
||||||
$candidate['next_due_date']->toDateString(),
|
$candidate['next_due_date']->toDateString(),
|
||||||
number_format($candidate['legacy_conditions']['total_cents'] / 100, 2, ',', '.'),
|
number_format($candidate['legacy_conditions']['net_cents'] / 100, 2, ',', '.'),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
40
app/Enums/VatTreatment.php
Normal file
40
app/Enums/VatTreatment.php
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,7 @@ class BillingAddress extends Model
|
||||||
'postal_code',
|
'postal_code',
|
||||||
'city',
|
'city',
|
||||||
'country_code',
|
'country_code',
|
||||||
|
'vat_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function user(): BelongsTo
|
public function user(): BelongsTo
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ class Invoice extends Model
|
||||||
'total_cents',
|
'total_cents',
|
||||||
'currency',
|
'currency',
|
||||||
'is_netto',
|
'is_netto',
|
||||||
|
'tax_note',
|
||||||
'invoice_date',
|
'invoice_date',
|
||||||
'due_date',
|
'due_date',
|
||||||
'paid_at',
|
'paid_at',
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ class InvoiceBillingAddress extends Model
|
||||||
'postal_code',
|
'postal_code',
|
||||||
'city',
|
'city',
|
||||||
'country_code',
|
'country_code',
|
||||||
|
'vat_id',
|
||||||
];
|
];
|
||||||
|
|
||||||
public function invoices(): HasMany
|
public function invoices(): HasMany
|
||||||
|
|
|
||||||
|
|
@ -22,14 +22,19 @@ use Illuminate\Support\Facades\Log;
|
||||||
* weitergeschaltet. Neue Abschlüsse laufen ausschließlich über Stripe
|
* weitergeschaltet. Neue Abschlüsse laufen ausschließlich über Stripe
|
||||||
* (STR-Kreis) und werden hier bewusst ausgeklammert.
|
* (STR-Kreis) und werden hier bewusst ausgeklammert.
|
||||||
*
|
*
|
||||||
* Konditions-Overrides pro Vereinbarung über `legacy_conditions` (JSON):
|
* Preisbasis ist immer NETTO (Entscheidung 12.06.2026): `legacy_conditions.
|
||||||
* `amount_cents`, `tax_cents`, `total_cents`, `interval` (monthly|yearly).
|
* net_cents` (von der Grandfather-Migration aus den Brutto-/Netto-Beträgen
|
||||||
* Ohne Override gilt der Netto-Preis der `payment_option` plus
|
* der letzten Legacy-Rechnung abgeleitet), sonst der Netto-Preis der
|
||||||
* `billing.vat_rate`.
|
* `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
|
class ManualInvoiceService
|
||||||
{
|
{
|
||||||
public function __construct(private readonly InvoiceNumberGenerator $numbers) {}
|
public function __construct(
|
||||||
|
private readonly InvoiceNumberGenerator $numbers,
|
||||||
|
private readonly VatResolver $vat,
|
||||||
|
) {}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return Collection<int, UserPaymentOption>
|
* @return Collection<int, UserPaymentOption>
|
||||||
|
|
@ -87,9 +92,14 @@ class ManualInvoiceService
|
||||||
return null;
|
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).
|
// Adresse pro Rechnung einfrieren (Snapshot-Tabelle).
|
||||||
$addressSnapshot = InvoiceBillingAddress::query()->create([
|
$addressSnapshot = InvoiceBillingAddress::query()->create([
|
||||||
'salutation_key' => $billingAddress->salutation_key,
|
'salutation_key' => $billingAddress->salutation_key,
|
||||||
|
|
@ -100,6 +110,7 @@ class ManualInvoiceService
|
||||||
'postal_code' => $billingAddress->postal_code,
|
'postal_code' => $billingAddress->postal_code,
|
||||||
'city' => $billingAddress->city,
|
'city' => $billingAddress->city,
|
||||||
'country_code' => $billingAddress->country_code,
|
'country_code' => $billingAddress->country_code,
|
||||||
|
'vat_id' => $billingAddress->vat_id,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
$invoice = Invoice::query()->create([
|
$invoice = Invoice::query()->create([
|
||||||
|
|
@ -107,10 +118,12 @@ class ManualInvoiceService
|
||||||
'invoice_billing_address_id' => $addressSnapshot->id,
|
'invoice_billing_address_id' => $addressSnapshot->id,
|
||||||
'number' => $this->numbers->nextManualNumber(),
|
'number' => $this->numbers->nextManualNumber(),
|
||||||
'status' => InvoiceStatus::Open->value,
|
'status' => InvoiceStatus::Open->value,
|
||||||
'amount_cents' => $amountCents,
|
'amount_cents' => $netCents,
|
||||||
'tax_cents' => $taxCents,
|
'tax_cents' => $taxCents,
|
||||||
'total_cents' => $totalCents,
|
'total_cents' => $netCents + $taxCents,
|
||||||
'currency' => $option->paymentOption?->currency ?? 'EUR',
|
'currency' => $option->paymentOption?->currency ?? 'EUR',
|
||||||
|
'is_netto' => $treatment->isTaxExempt(),
|
||||||
|
'tax_note' => $treatment->taxNote(),
|
||||||
'invoice_date' => $asOf,
|
'invoice_date' => $asOf,
|
||||||
'due_date' => $asOf->copy()->addDays((int) config('billing.manual_due_days', 14)),
|
'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 ?? [];
|
$conditions = $option->legacy_conditions ?? [];
|
||||||
|
|
||||||
if (isset($conditions['amount_cents'], $conditions['total_cents'])) {
|
return (int) ($conditions['net_cents'] ?? $option->paymentOption?->price_cents ?? 0);
|
||||||
$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];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
66
app/Services/Billing/VatResolver.php
Normal file
66
app/Services/Billing/VatResolver.php
Normal 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 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,8 +34,26 @@ return [
|
||||||
// Zahlungsziel für Rechnungen des manuellen Kreises (Tage).
|
// Zahlungsziel für Rechnungen des manuellen Kreises (Tage).
|
||||||
'manual_due_days' => env('BILLING_MANUAL_DUE_DAYS', 14),
|
'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),
|
'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
130
config/cashier.php
Normal 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'),
|
||||||
|
|
||||||
|
];
|
||||||
|
|
@ -40,5 +40,4 @@ return [
|
||||||
'model' => env('OPENAI_MODEL', 'gpt-5.4-mini'),
|
'model' => env('OPENAI_MODEL', 'gpt-5.4-mini'),
|
||||||
'timeout' => env('OPENAI_TIMEOUT', 60),
|
'timeout' => env('OPENAI_TIMEOUT', 60),
|
||||||
],
|
],
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
|
||||||
|
|
@ -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');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
@ -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.
|
- **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`:
|
- **Übernahme** als `grandfathered` in `user_payment_options`:
|
||||||
- `status = 'grandfathered'`, `grandfathered_until = legacy.valid_until_date` (nullable), `stripe_subscription_id = null`.
|
- `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.
|
- **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.
|
- 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).
|
- Alle historischen `user_payments` werden als Information ins Archiv geschrieben (analog `legacy_invoices` – optional).
|
||||||
|
|
|
||||||
|
|
@ -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.
|
**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.
|
**Enterprise:** sichtbar, aber als **dezenter Sales-Hinweis unterhalb der Tabelle** („Größere Mengen oder mehrere Marken? → Kontakt"). Keine eigene Preisspalte, individuelles Pricing.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,16 @@ ist hybrid mit zwei getrennten Rechnungskreisen (plus Altbestand):
|
||||||
- **`User::hasActiveBooking()`** prüft jetzt echt (hinter
|
- **`User::hasActiveBooking()`** prüft jetzt echt (hinter
|
||||||
`billing.enforce_booking`): Cashier-Abo ∨ bezahlter Einzel-/Extra-PM-Kauf
|
`billing.enforce_booking`): Cashier-Abo ∨ bezahlter Einzel-/Extra-PM-Kauf
|
||||||
∨ aktive/grandfathered Legacy-Vereinbarung (MAN-Kreis).
|
∨ 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
|
- **Legacy-Migration (12.06.)**: `legacy:grandfather-subscriptions` leitet
|
||||||
die aktiven, jährlich wiederkehrenden Vereinbarungen aus dem
|
die aktiven, jährlich wiederkehrenden Vereinbarungen aus dem
|
||||||
Rechnungsarchiv ab und schreibt sie als `grandfathered` in
|
Rechnungsarchiv ab und schreibt sie als `grandfathered` in
|
||||||
|
|
|
||||||
|
|
@ -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] 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] 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.
|
- [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.
|
- [ ] 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 €.
|
- [ ] Launch-Credits: Extra-PM, Boost (nur gruene PMs), Veroeffentlichungsnachweis-PDF; Credit-Anker 1 Credit = 1 €.
|
||||||
- [ ] Einzel→Abo-Bruecke (19 € Anrechnung innerhalb 30 Tagen).
|
- [ ] Einzel→Abo-Bruecke (19 € Anrechnung innerhalb 30 Tagen).
|
||||||
|
|
|
||||||
|
|
@ -143,6 +143,10 @@ new #[Layout('components.layouts.app'), Title('Mein Profil')] class extends Comp
|
||||||
'postal_code' => $validated['billingPostalCode'],
|
'postal_code' => $validated['billingPostalCode'],
|
||||||
'city' => $validated['billingCity'],
|
'city' => $validated['billingCity'],
|
||||||
'country_code' => $validated['billingCountryCode'],
|
'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,
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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_start->toDateString())->toBe('2025-08-01');
|
||||||
expect($agreement->current_period_end->toDateString())->toBe('2026-08-01');
|
expect($agreement->current_period_end->toDateString())->toBe('2026-08-01');
|
||||||
expect($agreement->legacy_conditions['interval'])->toBe('yearly');
|
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);
|
expect($agreement->legacy_conditions['legacy_user_payment_option_id'])->toBe(42);
|
||||||
|
|
||||||
$catalog = PaymentOption::query()->where('article_number', 'LEGACY-PE-PK-01')->sole();
|
$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();
|
$agreement = UserPaymentOption::sole();
|
||||||
expect($agreement->current_period_end->toDateString())->toBe('2026-08-01');
|
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 () {
|
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);
|
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();
|
$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, [
|
legacyArchiveInvoice($user, [
|
||||||
'invoice_date' => '2025-05-14',
|
'invoice_date' => '2025-05-14',
|
||||||
'amount_cents' => 19900,
|
'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());
|
$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->number)->toBe('MAN-00001');
|
||||||
expect($invoice->amount_cents)->toBe(19900);
|
expect($invoice->amount_cents)->toBe(16723);
|
||||||
expect($invoice->tax_cents)->toBe(0);
|
expect($invoice->tax_cents)->toBe(3177);
|
||||||
expect($invoice->total_cents)->toBe(19900);
|
expect($invoice->total_cents)->toBe(19900);
|
||||||
|
expect($invoice->is_netto)->toBeFalse();
|
||||||
|
|
||||||
// Periode jährlich weitergeschaltet.
|
// Periode jährlich weitergeschaltet.
|
||||||
expect($due->first()->fresh()->current_period_end->toDateString())->toBe('2027-05-14');
|
expect($due->first()->fresh()->current_period_end->toDateString())->toBe('2027-05-14');
|
||||||
|
|
|
||||||
|
|
@ -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');
|
Carbon::setTestNow('2026-06-12 08:00:00');
|
||||||
|
|
||||||
$agreement = manualAgreement([
|
$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)->not->toBeNull();
|
||||||
expect($invoice->number)->toBe('MAN-00001');
|
expect($invoice->number)->toBe('MAN-00001');
|
||||||
expect($invoice->status)->toBe(InvoiceStatus::Open);
|
expect($invoice->status)->toBe(InvoiceStatus::Open);
|
||||||
|
// Netto-Preisbasis 49,00 € + 19 % USt (Adresse DE).
|
||||||
expect($invoice->amount_cents)->toBe(4900);
|
expect($invoice->amount_cents)->toBe(4900);
|
||||||
expect($invoice->tax_cents)->toBe(931);
|
expect($invoice->tax_cents)->toBe(931);
|
||||||
expect($invoice->total_cents)->toBe(5831);
|
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');
|
expect($invoice->due_date->toDateString())->toBe('2026-06-26');
|
||||||
|
|
||||||
$fresh = $agreement->fresh();
|
$fresh = $agreement->fresh();
|
||||||
|
|
@ -58,15 +61,13 @@ test('a due manual agreement gets a MAN invoice and the period advances', functi
|
||||||
Carbon::setTestNow();
|
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');
|
Carbon::setTestNow('2026-06-12 08:00:00');
|
||||||
|
|
||||||
$agreement = manualAgreement([
|
$agreement = manualAgreement([
|
||||||
'current_period_end' => '2026-06-10',
|
'current_period_end' => '2026-06-10',
|
||||||
'legacy_conditions' => [
|
'legacy_conditions' => [
|
||||||
'amount_cents' => 10000,
|
'net_cents' => 10000,
|
||||||
'tax_cents' => 1900,
|
|
||||||
'total_cents' => 11900,
|
|
||||||
'interval' => 'yearly',
|
'interval' => 'yearly',
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
@ -81,6 +82,43 @@ test('legacy_conditions override amounts and interval', function () {
|
||||||
Carbon::setTestNow();
|
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 () {
|
test('the invoice freezes the billing address as a snapshot', function () {
|
||||||
$agreement = manualAgreement();
|
$agreement = manualAgreement();
|
||||||
$agreement->user->billingAddress->update(['name' => 'Alpha GmbH', 'city' => 'Berlin']);
|
$agreement->user->billingAddress->update(['name' => 'Alpha GmbH', 'city' => 'Berlin']);
|
||||||
|
|
|
||||||
33
tests/Feature/Billing/VatResolverTest.php
Normal file
33
tests/Feature/Billing/VatResolverTest.php
Normal 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();
|
||||||
|
});
|
||||||
Loading…
Add table
Add a link
Reference in a new issue