presseportale/tests/Feature/Billing/LegacySettleUnpaidCommandTest.php
Kevin Adametz 1c38c820f4 reports: Kohortenalter ab Stichtag statt ab --to; legacy:settle-unpaid kennt bereits ersetzte Quellen; dev: Basiswert 0.1 vom Live-System ausgewertet
view-distribution rechnete das Alter der Kohorten ab dem Enddatum des
Fensters, die Zähler stehen aber immer auf heute — mit einem --to in der
Vergangenheit waren alle Kohorten um die Verzögerung des Laufs zu jung.
Das Alter zählt jetzt ab dem Stichtag der Erhebung (Feld stichtag), junge
Kohorten fallen bei altem Enddatum leer aus.

settle-unpaid meldete beim zweiten Lauf einen Posten, dessen Quelle der
erste Lauf schon per MAN-Rechnung ersetzt hatte (superseded), als
Konflikt. Solche Quellen zählen jetzt als already_reissued.

Basiswerte Block 0: Live-Erhebung vom 21.09.2026 (gleiches Fenster,
Median 126/303 statt 98/200) eingetragen, Kohortenzeile mit Alters-
Versatz erklärt, letzter Schritt für 0.1 benannt (Lauf ohne --to).
Protokoll: Nach-Deploy-Schritte, Lesart von Audit und Rechnungslauf.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-21 15:32:32 +00:00

236 lines
9.6 KiB
PHP

<?php
use App\Console\Commands\SettleUnpaidLegacyInvoices;
use App\Enums\InvoiceStatus;
use App\Enums\UserPaymentOptionStatus;
use App\Models\ActivityEvent;
use App\Models\BillingAddress;
use App\Models\Invoice;
use App\Models\InvoiceBillingAddress;
use App\Models\LegacyInvoice;
use App\Models\PaymentOption;
use App\Models\User;
use App\Models\UserPaymentOption;
use Illuminate\Support\Carbon;
/**
* Bestandsschutz-Posten samt Quellrechnung im Archiv, so wie die
* Grandfather-Migration ihn vor der Korrektur angelegt hat.
*
* @return array{0: UserPaymentOption, 1: LegacyInvoice}
*/
function settleFixture(User $user, string $status, array $invoiceOverrides = [], array $postOverrides = []): array
{
static $legacyId = 5000;
$legacyId++;
$legacy = LegacyInvoice::query()->create(array_replace_recursive([
'legacy_portal' => 'businessportal24',
'legacy_id' => $legacyId,
'user_id' => $user->id,
'legacy_user_id' => 70000 + $legacyId,
'number' => 'BP-'.$legacyId,
'amount_cents' => 19900,
'tax_cents' => 0,
'total_cents' => 19900,
'status' => $status,
'invoice_date' => '2026-06-30',
'raw_snapshot' => [
'is_netto' => false,
'service_period_begin_date' => '2026-06-14',
'service_period_end_date' => '2027-06-13',
],
'pdf_payload' => [
'user_payment_option' => ['id' => $legacyId, 'status' => 'active', 'next_due_date' => '2027-06-14'],
'payment_option' => ['id' => 7, 'type' => 'recurring', 'article_number' => 'BP-01'],
'payment_option_translation' => ['name' => 'Unbegrenzte PMs'],
],
'imported_at' => now(),
], $invoiceOverrides));
$post = UserPaymentOption::factory()->create(array_replace([
'user_id' => $user->id,
'payment_option_id' => PaymentOption::factory()->create(['interval' => 'yearly'])->id,
'status' => UserPaymentOptionStatus::Grandfathered->value,
'grandfathered_until' => null,
'stripe_subscription_id' => null,
'cancelled_at' => null,
'current_period_start' => '2026-06-14',
'current_period_end' => '2027-06-14',
'legacy_conditions' => [
'legacy_portal' => 'businessportal24',
'interval' => 'yearly',
'net_cents' => 16723,
'name' => 'Unbegrenzte PMs',
'source_invoice_number' => $legacy->number,
],
], $postOverrides));
return [$post, $legacy];
}
function manInvoiceFor(UserPaymentOption $post, string $number): Invoice
{
return Invoice::factory()->create([
'user_id' => $post->user_id,
'user_payment_option_id' => $post->id,
'user_payment_id' => null,
'invoice_billing_address_id' => InvoiceBillingAddress::factory(),
'number' => $number,
'status' => InvoiceStatus::Open->value,
'stripe_invoice_id' => null,
'total_cents' => 19900,
]);
}
beforeEach(function (): void {
Carbon::setTestNow('2026-09-16 09:00:00');
});
afterEach(function (): void {
Carbon::setTestNow();
});
test('hold-derived bundles are cancelled and their open MAN invoices voided', function () {
$user = User::factory()->create();
[$post] = settleFixture($user, 'hold');
$invoice = manInvoiceFor($post, 'MAN-00001');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect($post->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled);
expect($post->fresh()->cancelled_at)->not->toBeNull();
expect($invoice->fresh()->status)->toBe(InvoiceStatus::Void);
expect($invoice->fresh()->voided_at)->not->toBeNull();
expect($invoice->fresh()->void_reason)->toContain('nie bezahlt');
expect(ActivityEvent::query()->where('type', 'manual_invoice')->count())->toBe(1);
expect(ActivityEvent::query()->where('type', 'subscription_change')->count())->toBe(1);
});
test('the hold archive rows stay untouched', function () {
$user = User::factory()->create();
[, $legacy] = settleFixture($user, 'hold');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect($legacy->fresh()->status)->toBe('hold');
expect(LegacyInvoice::count())->toBe(1);
});
test('paid-derived posts are left alone', function () {
$user = User::factory()->create();
[$post] = settleFixture($user, 'paid');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect($post->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
expect($post->fresh()->current_period_end->toDateString())->toBe('2027-06-14');
expect(Invoice::count())->toBe(0);
});
test('open reminder posts are rewound and re-issued as MAN invoices replacing the legacy one', function () {
$user = User::factory()->create();
BillingAddress::factory()->create(['user_id' => $user->id, 'country_code' => 'DE']);
[$post, $legacy] = settleFixture($user, 'reminder');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
$invoice = Invoice::query()->manual()->sole();
expect($invoice->status)->toBe(InvoiceStatus::Open);
expect($invoice->user_payment_option_id)->toBe($post->id);
expect($invoice->replaces_legacy_invoice_number)->toBe($legacy->number);
expect($invoice->invoice_date->toDateString())->toBe('2026-09-16');
expect($invoice->amount_cents)->toBe(16723);
expect($invoice->total_cents)->toBe(19900);
// Rhythmus bleibt: die neu ausgestellte Periode endet wie im Altsystem 2027.
expect($post->fresh()->current_period_start->toDateString())->toBe('2026-06-14');
expect($post->fresh()->current_period_end->toDateString())->toBe('2027-06-14');
expect($post->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
expect($legacy->fresh()->status)->toBe(LegacyInvoice::STATUS_SUPERSEDED);
});
test('an open post without billing address is rewound and reported as conflict for the daily run', function () {
$user = User::factory()->create();
[$post, $legacy] = settleFixture($user, 'pending');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])
->expectsOutputToContain('Konflikt')
->assertSuccessful();
expect(Invoice::count())->toBe(0);
expect($post->fresh()->current_period_end->toDateString())->toBe('2026-06-14');
expect($post->fresh()->legacy_conditions['open_legacy_invoice']['number'])->toBe($legacy->number);
expect($legacy->fresh()->status)->toBe('pending');
});
test('a mixed bundle (hold + paid) is not cancelled but reported', function () {
$user = User::factory()->create();
[$holdPost] = settleFixture($user, 'hold');
[$paidPost] = settleFixture($user, 'paid', ['legacy_portal' => 'presseecho'], []);
$paidPost->update(['legacy_conditions' => array_merge($paidPost->legacy_conditions, ['legacy_portal' => 'presseecho'])]);
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])
->expectsOutputToContain('gemischten Bündel')
->assertSuccessful();
expect($holdPost->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
expect($paidPost->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
});
test('open single purchases are written off', function () {
$user = User::factory()->create();
$single = LegacyInvoice::query()->create([
'legacy_portal' => 'businessportal24',
'legacy_id' => 9001,
'user_id' => $user->id,
'legacy_user_id' => 79001,
'number' => 'BP-9001',
'amount_cents' => 1995,
'tax_cents' => 0,
'total_cents' => 1995,
'status' => 'pending',
'invoice_date' => '2026-07-08',
'raw_snapshot' => [],
'pdf_payload' => ['payment_option' => ['type' => 'single'], 'user_payment_option' => ['id' => 1, 'status' => 'active']],
'imported_at' => now(),
]);
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect($single->fresh()->status)->toBe(LegacyInvoice::STATUS_UNCOLLECTIBLE);
});
test('dry-run writes nothing and a second real run is a no-op', function () {
$user = User::factory()->create();
BillingAddress::factory()->create(['user_id' => $user->id]);
[$holdPost] = settleFixture($user, 'hold');
$other = User::factory()->create();
BillingAddress::factory()->create(['user_id' => $other->id]);
settleFixture($other, 'reminder');
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--dry-run' => true, '--no-report' => true])->assertSuccessful();
expect($holdPost->fresh()->status)->toBe(UserPaymentOptionStatus::Grandfathered);
expect(Invoice::count())->toBe(0);
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
expect($holdPost->fresh()->status)->toBe(UserPaymentOptionStatus::Cancelled);
expect(Invoice::count())->toBe(1);
// Zweiter Lauf: die schon ersetzte Alt-Rechnung ist kein Konflikt, sondern erledigt.
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])
->doesntExpectOutputToContain('Konflikt')
->expectsTable(['Ergebnis', 'Anzahl'], [
['bundles_cancelled', 0],
['man_invoices_voided', 0],
['reopened', 0],
['man_invoices_issued', 0],
['singles_written_off', 0],
['conflicts', 0],
['untouched_paid', 0],
['already_reissued', 1],
])
->assertSuccessful();
expect(Invoice::count())->toBe(1);
expect(UserPaymentOption::query()->where('status', UserPaymentOptionStatus::Cancelled->value)->count())->toBe(1);
});