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); $this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful(); expect(Invoice::count())->toBe(1); expect(UserPaymentOption::query()->where('status', UserPaymentOptionStatus::Cancelled->value)->count())->toBe(1); });