Bestandskunden aus dem Altsystem zahlen per Ueberweisung; nach dem 04:30-Lauf fehlte bisher der gesamte Bearbeitungsweg (kein PDF, keine Mail, kein Zahlungseingang, kein Bestandsschutz-Ende). Plan und Befund: dev/lagacy-rechnungen/entwicklungsplan-2026-09-16.md. Bereinigung (legacy:settle-unpaid, wiederholbar): - Legacy-Status hold = nie bezahlt, ersatzlos erledigt. 9 daraus abgeleitete Bestandsschutz-Buendel werden gekuendigt, MAN-00001/00002 storniert. Archivzeilen bleiben erhalten, sind aber in Admin-Archiv und Kundenkonto standardmaessig ausgeblendet. - pending/reminder (recurring) = offene Vorgaenge: Periode auf den Leistungsbeginn der unbezahlten Rechnung zurueckgedreht und als MAN-Rechnung neu ausgestellt (Archiv-Status superseded, replaces_legacy_invoice_number). - pending/reminder (single) = abgeschrieben (uncollectible). - legacy:grandfather-subscriptions wertet den Zahlungsstatus jetzt selbst aus. Workflow in /admin/payments (ManualInvoiceDunningService, je Aktion mit Eintrag im Aktivitaets-Verlauf): - Rechnung senden: Mail mit PDF-Anhang, Bankverbindung aus billing.issuer, Hinweis auf Bestandsschutz-Ende; Zahlungsziel zaehlt ab Versand. - Erinnerung: gleiche Mail im Mahn-Ton, Nachfrist bei verstrichener Frist. - Bezahlt: Zahlungseingang laut Kontoauszug verbuchen. - Bestandsschutz beenden: Rechnung storniert (nie geloescht), Buendel gekuendigt, weitere offene MAN-Rechnungen des Kunden storniert, optionale Kundenmail. - Panel "Blockierte Vereinbarungen": faellige Posten ohne Rechnung (meist fehlende Rechnungsadresse) mit "Jetzt abrechnen" oder "Entfernen". Schema: invoices bekommt user_payment_option_id, sent_at, reminded_at, reminder_count, voided_at, void_reason, replaces_legacy_invoice_number (inkl. Backfill der bestehenden MAN-Rechnungen). PDF-Primitiven des Archiv-Renderers in den Trait DrawsPdf ausgelagert; MAN-PDF on-demand ueber admin.invoices.pdf / me.invoices.manual-pdf (InvoicePolicy). Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
223 lines
9.2 KiB
PHP
223 lines
9.2 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);
|
|
|
|
$this->artisan(SettleUnpaidLegacyInvoices::class, ['--no-report' => true])->assertSuccessful();
|
|
expect(Invoice::count())->toBe(1);
|
|
expect(UserPaymentOption::query()->where('status', UserPaymentOptionStatus::Cancelled->value)->count())->toBe(1);
|
|
});
|