Block 5 — MAN-Abrechnung F06 (E6): Ein explizites Legacy-Vertragsende (grandfathered_until) deckelt jetzt Auswahl und Sperr-Nachprüfung: Beginnt die nächste Periode nach dem Vertragsende, gibt es keine Rechnung mehr. Neuer täglicher Befehl billing:expire-grandfathering (04:15, vor dem Rechnungslauf) setzt abgelaufene Posten auf gekündigt und schickt je Kunde eine Mail (GrandfatheringExpired). Auf der Dev-Kopie trägt kein Posten ein Vertragsende; der Fehler war latent. F16: Nicht abrechenbare Vereinbarungen bekommen einen Sperrvermerk (billing_hold_reason/-at), sichtbar auf der Admin-Zahlungsseite, und stehen im Rechnungslauf hinten an. Der Lauf arbeitet den fälligen Bestand in Seiten ab; --limit deckelt die erstellten Rechnungen (500), nicht die betrachteten Fälle. Vorher blockierten 50 unvollständige Fälle die 51. dauerhaft, und der Durchsatz war auf 50 am Tag begrenzt. Block 6 — Entwicklung F24: Lint-Workflow prüft mit pint --test statt zu formatieren; keine Schreibrechte mehr. F27: composer run dev startet beide Vite-Server (npm run dev:all). F28: docs/README.md als historischer Stand markiert, toter Link auf das Hub-Flux-Archiv korrigiert, aktueller Einstieg dev/README.md. F26: Login-Modal übernimmt den Fokus, hält Tab im Dialog und gibt den Fokus beim Schließen an den Auslöser zurück – ohne neue Abhängigkeit. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
268 lines
10 KiB
PHP
268 lines
10 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('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);
|
|
});
|