presseportale/tests/Feature/Billing/ManualInvoiceGenerationTest.php
Kevin Adametz 894a9436b0 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>
2026-06-12 10:58:43 +00:00

175 lines
6.2 KiB
PHP

<?php
use App\Console\Commands\GenerateManualInvoices;
use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Models\BillingAddress;
use App\Models\Invoice;
use App\Models\PaymentOption;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Billing\ManualInvoiceService;
use Illuminate\Support\Carbon;
function manualAgreement(array $overrides = [], array $optionOverrides = []): UserPaymentOption
{
$user = User::factory()->create();
BillingAddress::factory()->create(['user_id' => $user->id]);
$paymentOption = PaymentOption::factory()->create([
'price_cents' => 4900,
'interval' => 'monthly',
...$optionOverrides,
]);
return UserPaymentOption::factory()->create([
'user_id' => $user->id,
'payment_option_id' => $paymentOption->id,
'status' => UserPaymentOptionStatus::Active->value,
'stripe_subscription_id' => null,
'current_period_start' => today()->subMonth(),
'current_period_end' => today()->subDay(),
'legacy_conditions' => null,
...$overrides,
]);
}
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([
'current_period_end' => '2026-06-11',
]);
$invoice = app(ManualInvoiceService::class)->invoiceFor($agreement);
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();
expect($fresh->current_period_start->toDateString())->toBe('2026-06-11');
expect($fresh->current_period_end->toDateString())->toBe('2026-07-11');
Carbon::setTestNow();
});
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' => [
'net_cents' => 10000,
'interval' => 'yearly',
],
]);
$invoice = app(ManualInvoiceService::class)->invoiceFor($agreement);
expect($invoice->amount_cents)->toBe(10000);
expect($invoice->tax_cents)->toBe(1900);
expect($invoice->total_cents)->toBe(11900);
expect($agreement->fresh()->current_period_end->toDateString())->toBe('2027-06-10');
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']);
$invoice = app(ManualInvoiceService::class)->invoiceFor($agreement);
$agreement->user->billingAddress->update(['name' => 'Beta GmbH', 'city' => 'Hamburg']);
expect($invoice->fresh()->invoiceBillingAddress->name)->toBe('Alpha GmbH');
expect($invoice->fresh()->invoiceBillingAddress->city)->toBe('Berlin');
});
test('agreements without a billing address are skipped and keep their period', function () {
$agreement = manualAgreement();
$agreement->user->billingAddress->delete();
$periodEnd = $agreement->current_period_end->toDateString();
$invoice = app(ManualInvoiceService::class)->invoiceFor($agreement->fresh());
expect($invoice)->toBeNull();
expect(Invoice::count())->toBe(0);
expect($agreement->fresh()->current_period_end->toDateString())->toBe($periodEnd);
});
test('stripe-managed agreements are never picked up by the manual circle', function () {
manualAgreement(['stripe_subscription_id' => 'sub_123']);
expect(app(ManualInvoiceService::class)->duePaymentOptions())->toHaveCount(0);
});
test('agreements with a future period end are not due', function () {
manualAgreement(['current_period_end' => today()->addWeek()]);
expect(app(ManualInvoiceService::class)->duePaymentOptions())->toHaveCount(0);
});
test('the command invoices all due agreements', function () {
manualAgreement();
manualAgreement();
$this->artisan(GenerateManualInvoices::class)->assertSuccessful();
expect(Invoice::count())->toBe(2);
expect(Invoice::pluck('number')->sort()->values()->all())->toBe(['MAN-00001', 'MAN-00002']);
});
test('the command dry-run does not create invoices', function () {
manualAgreement();
$this->artisan(GenerateManualInvoices::class, ['--dry-run' => true])->assertSuccessful();
expect(Invoice::count())->toBe(0);
});