billing/dev: Vertragsende in der MAN-Abrechnung, Rechnungslauf ohne Tageskappe, Qualitätsgates (Audit Block 5+6, Deploy E)
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>
This commit is contained in:
parent
f235156de9
commit
375f4dffce
15 changed files with 518 additions and 65 deletions
61
tests/Feature/Billing/ExpireGrandfatheringTest.php
Normal file
61
tests/Feature/Billing/ExpireGrandfatheringTest.php
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
<?php
|
||||
|
||||
use App\Console\Commands\ExpireGrandfathering;
|
||||
use App\Enums\UserPaymentOptionStatus;
|
||||
use App\Mail\GrandfatheringExpired;
|
||||
use App\Models\BillingAddress;
|
||||
use App\Models\PaymentOption;
|
||||
use App\Models\User;
|
||||
use App\Models\UserPaymentOption;
|
||||
use Illuminate\Support\Carbon;
|
||||
use Illuminate\Support\Facades\Mail;
|
||||
use Tests\TestCase;
|
||||
|
||||
/**
|
||||
* Explizites Legacy-Vertragsende (Audit F06, Plan 5.2): Bestandsschutz mit
|
||||
* überschrittenem `grandfathered_until` wird beendet und der Kunde
|
||||
* informiert – bisher passierte das nur manuell über die Mahnung.
|
||||
*/
|
||||
function grandfatheredAgreement(?string $until = null, array $overrides = []): UserPaymentOption
|
||||
{
|
||||
$user = User::factory()->create();
|
||||
BillingAddress::factory()->create(['user_id' => $user->id]);
|
||||
|
||||
return UserPaymentOption::factory()->create([
|
||||
'user_id' => $user->id,
|
||||
'payment_option_id' => PaymentOption::factory()->create()->id,
|
||||
'status' => UserPaymentOptionStatus::Grandfathered->value,
|
||||
'stripe_subscription_id' => null,
|
||||
'grandfathered_until' => $until,
|
||||
'cancelled_at' => null,
|
||||
...$overrides,
|
||||
]);
|
||||
}
|
||||
|
||||
test('grandfathering past its contract end is ended and the customer is told once', function () {
|
||||
/** @var TestCase $this */
|
||||
Mail::fake();
|
||||
Carbon::setTestNow('2026-09-18 04:15:00');
|
||||
|
||||
$expired = grandfatheredAgreement('2026-09-17');
|
||||
$endsToday = grandfatheredAgreement('2026-09-18');
|
||||
$open = grandfatheredAgreement(null);
|
||||
$alreadyCancelled = grandfatheredAgreement('2026-09-01', ['status' => UserPaymentOptionStatus::Cancelled->value, 'cancelled_at' => now()->subDay()]);
|
||||
|
||||
$this->artisan(ExpireGrandfathering::class)->assertSuccessful();
|
||||
|
||||
expect($expired->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled)
|
||||
->and($expired->fresh()->cancelled_at)->not->toBeNull()
|
||||
->and($endsToday->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered)
|
||||
->and($open->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
|
||||
|
||||
Mail::assertQueued(GrandfatheringExpired::class, fn (GrandfatheringExpired $mail) => $mail->user->is($expired->user));
|
||||
Mail::assertQueuedCount(1);
|
||||
|
||||
// Wiederholung ist wirkungslos.
|
||||
$this->artisan(ExpireGrandfathering::class)->assertSuccessful();
|
||||
Mail::assertQueuedCount(1);
|
||||
expect($alreadyCancelled->fresh()->cancelled_at->toDateString())->toBe('2026-09-17');
|
||||
|
||||
Carbon::setTestNow();
|
||||
});
|
||||
|
|
@ -189,3 +189,80 @@ test('the command dry-run does not create invoices', function () {
|
|||
|
||||
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);
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue