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('a stale due agreement instance cannot be invoiced twice', function () { Carbon::setTestNow('2026-06-12 08:00:00'); $agreement = manualAgreement(['current_period_end' => '2026-06-11']); $service = app(ManualInvoiceService::class); $firstInvoice = $service->invoiceFor($agreement); $replayedInvoice = $service->invoiceFor($agreement); expect($firstInvoice)->not->toBeNull(); expect($replayedInvoice)->toBeNull(); expect(Invoice::count())->toBe(1); expect($agreement->fresh()->current_period_end->toDateString())->toBe('2026-07-11'); Carbon::setTestNow(); }); 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); }); test('no MAN invoice is issued for a period that starts after the legacy contract end (Audit F06, E6)', function () { Carbon::setTestNow('2026-09-18 08:00:00'); $service = app(ManualInvoiceService::class); // Vorgestern beendet: die nächste Periode (ab 17.09.) läge nach dem Vertragsende. $ended = manualAgreement([ 'status' => UserPaymentOptionStatus::Grandfathered->value, 'current_period_end' => '2026-09-17', 'grandfathered_until' => '2026-09-16', ]); // Heute endend, Periode beginnt heute: noch abrechenbar. $endsToday = manualAgreement([ 'status' => UserPaymentOptionStatus::Grandfathered->value, 'current_period_end' => '2026-09-18', 'grandfathered_until' => '2026-09-18', ]); // Vertragsende vor Periodenbeginn: nicht mehr abrechenbar. $expired = manualAgreement([ 'status' => UserPaymentOptionStatus::Grandfathered->value, 'current_period_end' => '2026-09-10', 'grandfathered_until' => '2026-09-01', ]); // Unbefristet: wie bisher. $open = manualAgreement([ 'status' => UserPaymentOptionStatus::Grandfathered->value, 'current_period_end' => '2026-09-10', 'grandfathered_until' => null, ]); expect($service->duePaymentOptions()->pluck('id')->all())->toBe([$open->id, $endsToday->id]); // Der direkte Aufruf beachtet dieselbe Grenze unter der Sperre. expect($service->invoiceFor($expired))->toBeNull() ->and($service->invoiceFor($ended))->toBeNull() ->and($service->invoiceFor($endsToday))->not->toBeNull() ->and($service->invoiceFor($open))->not->toBeNull() ->and($expired->fresh()->current_period_end->toDateString())->toBe('2026-09-10'); Carbon::setTestNow(); }); test('a skipped agreement is marked with a hold reason and cleared once invoiced (Audit F16)', function () { $agreement = manualAgreement(); $agreement->user->billingAddress->delete(); expect(app(ManualInvoiceService::class)->invoiceFor($agreement->fresh()))->toBeNull(); $fresh = $agreement->fresh(); expect($fresh->billing_hold_reason)->toBe('Keine Rechnungsadresse') ->and($fresh->billing_hold_at)->not->toBeNull(); BillingAddress::factory()->create(['user_id' => $agreement->user_id]); expect(app(ManualInvoiceService::class)->invoiceFor($agreement->fresh()))->not->toBeNull(); expect($agreement->fresh()->billing_hold_reason)->toBeNull(); }); test('the daily run works through every due agreement instead of the fifty oldest (Audit F16)', function () { /** @var TestCase $this */ // 50 blockierte Vereinbarungen sind älter als die eine abrechenbare. for ($i = 0; $i < 50; $i++) { $blocked = manualAgreement(['current_period_end' => today()->subDays(30 + $i)]); $blocked->user->billingAddress->delete(); } $billable = manualAgreement(['current_period_end' => today()->subDay()]); $this->artisan(GenerateManualInvoices::class, ['--limit' => 50])->assertSuccessful(); expect(Invoice::count())->toBe(1) ->and(Invoice::sole()->user_payment_option_id)->toBe($billable->id) ->and(UserPaymentOption::whereNotNull('billing_hold_reason')->count())->toBe(50); // Zweiter Lauf: die Blockierten stehen jetzt hinten an, nichts Neues. $this->artisan(GenerateManualInvoices::class, ['--limit' => 50])->assertSuccessful(); expect(Invoice::count())->toBe(1); });