presseportale/tests/Feature/Billing/LegacyBillingAddressImportTest.php
Kevin Adametz 3320aaaa85 Rechnungsadressen der Bestandskunden aus dem Legacy-Archiv uebernehmen
Befund 06.07.: Der MAN-Rechnungslauf ueberspringt Vereinbarungen ohne
Rechnungsadresse — der Bestandsschutz laeuft dann am Periodenende faktisch
aus. Der Grandfather-Import nahm die Adressen bisher nicht mit, weil die
Altportale sie nur als Freitext-Blob fuehren.

- legacy:import-billing-addresses: uebernimmt Adressen der grandfathered
  Kunden aus der Quell-Rechnung im Archiv (pdf_payload.billing_address),
  nie ueberschreibend, mit Dry-Run und Report der manuell nachzupflegenden
  Faelle (leere/unparsebare Blobs, unbekannte Laender)
- LegacyAddressParser: Best-Effort-Strukturierung der Blobs (mehrzeilig,
  kommasepariert, CH-PLZ-Praefix, Firmen-/Zusatzzeilen); raet nie
- Laenderaufloesung ueber die country-Tabellen der Legacy-Verbindungen
- config/countries.php: CY ergaenzt (reale Bestandskunden aus Zypern)
- WS-7- und QA-Checkliste: Import-Schritt nach dem Grandfathering

Lokal: 11 von 21 Bestandskunden importiert, 9 im Report (7 leere Blobs);
MAN-Lauf rechnet zuvor blockierte Vereinbarungen jetzt ab.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-06 12:19:50 +00:00

160 lines
6.4 KiB
PHP

<?php
use App\Enums\UserPaymentOptionStatus;
use App\Models\BillingAddress;
use App\Models\LegacyInvoice;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\Billing\LegacyAddressParser;
use App\Services\Billing\LegacyBillingAddressImporter;
use Illuminate\Foundation\Testing\RefreshDatabase;
uses(RefreshDatabase::class);
const COUNTRY_NAMES = [
'presseecho' => [108 => 'Cyprus', 177 => 'Germany', 196 => 'Switzerland'],
'businessportal24' => [108 => 'Cyprus', 177 => 'Germany', 196 => 'Switzerland'],
];
function createGrandfatheredUserWithArchiveAddress(array $billingAddressPayload, string $portal = 'presseecho'): User
{
$user = User::factory()->create();
LegacyInvoice::query()->create([
'legacy_portal' => $portal,
'legacy_id' => fake()->unique()->numberBetween(1, 100000),
'user_id' => $user->id,
'legacy_user_id' => $user->id,
'number' => 'LEG-'.fake()->unique()->numberBetween(1, 100000),
'total_cents' => 119000,
'status' => 'paid',
'invoice_date' => '2025-06-15',
'paid_at' => now()->subYear(),
'imported_at' => now(),
'pdf_payload' => ['billing_address' => $billingAddressPayload],
]);
$invoiceNumber = LegacyInvoice::query()->where('user_id', $user->id)->value('number');
UserPaymentOption::factory()->create([
'user_id' => $user->id,
'status' => UserPaymentOptionStatus::Grandfathered->value,
'grandfathered_until' => null,
'stripe_subscription_id' => null,
'legacy_conditions' => [
'legacy_portal' => $portal,
'source_invoice_number' => $invoiceNumber,
'interval' => 'yearly',
'net_cents' => 100000,
],
]);
return $user;
}
test('importer creates a structured billing address from the legacy blob', function () {
$user = createGrandfatheredUserWithArchiveAddress([
'name' => 'Sebastian Einbock',
'address' => "Einbock GmbH\r\nPrinzenstraße 1\r\n30419 Hannover",
'country_id' => 177,
]);
$report = app(LegacyBillingAddressImporter::class)->import(COUNTRY_NAMES);
expect($report['imported'])->toBe(1);
$address = BillingAddress::query()->where('user_id', $user->id)->first();
expect($address)->not->toBeNull()
->and($address->name)->toBe('Sebastian Einbock')
->and($address->company)->toBe('Einbock GmbH')
->and($address->address1)->toBe('Prinzenstraße 1')
->and($address->postal_code)->toBe('30419')
->and($address->city)->toBe('Hannover')
->and($address->country_code)->toBe('DE');
});
test('importer never overwrites an existing billing address', function () {
$user = createGrandfatheredUserWithArchiveAddress([
'name' => 'Alt Name',
'address' => "Altstraße 1\r\n11111 Altstadt",
'country_id' => 177,
]);
BillingAddress::factory()->create(['user_id' => $user->id, 'address1' => 'Vom Kunden gepflegt 5']);
$report = app(LegacyBillingAddressImporter::class)->import(COUNTRY_NAMES);
expect($report['imported'])->toBe(0)
->and($report['skipped_existing'])->toBe(1)
->and($user->billingAddress()->first()->address1)->toBe('Vom Kunden gepflegt 5');
});
test('unparseable blobs and unknown countries are reported instead of guessed', function () {
$empty = createGrandfatheredUserWithArchiveAddress([
'name' => 'Leerer Blob', 'address' => '', 'country_id' => 177,
]);
$unknownCountry = createGrandfatheredUserWithArchiveAddress([
'name' => 'Exot', 'address' => "Somewhere 1\r\n12345 Faraway", 'country_id' => 999,
]);
$report = app(LegacyBillingAddressImporter::class)->import(COUNTRY_NAMES);
expect($report['imported'])->toBe(0)
->and($report['unparseable'])->toHaveCount(1)
->and($report['unparseable'][0])->toContain($empty->email)
->and($report['unknown_country'])->toHaveCount(1)
->and($report['unknown_country'][0])->toContain($unknownCountry->email)
->and(BillingAddress::query()->count())->toBe(0);
});
test('dry run reports without writing', function () {
createGrandfatheredUserWithArchiveAddress([
'name' => 'Trocken Lauf',
'address' => "Teststraße 2\r\n22222 Testhausen",
'country_id' => 177,
]);
$report = app(LegacyBillingAddressImporter::class)->import(COUNTRY_NAMES, dryRun: true);
expect($report['imported'])->toBe(1)
->and(BillingAddress::query()->count())->toBe(0);
});
test('parser handles the address shapes found in the legacy archive', function (string $blob, ?string $countryName, ?array $expected) {
$parsed = app(LegacyAddressParser::class)->parse($blob, $countryName);
if ($expected === null) {
expect($parsed)->toBeNull();
} else {
expect($parsed)->toMatchArray($expected);
}
})->with([
'mehrzeilig mit Firma' => [
"Einbock GmbH\r\nPrinzenstraße 1\r\n30419 Hannover", 'Germany',
['company' => 'Einbock GmbH', 'address1' => 'Prinzenstraße 1', 'postal_code' => '30419', 'city' => 'Hannover'],
],
'Adresszusatz vor PLZ-Zeile' => [
"73, Makarios Avenue\r\n5th Floor\r\n1070 Nicosia", 'Cyprus',
['company' => null, 'address1' => '73, Makarios Avenue', 'address2' => '5th Floor', 'postal_code' => '1070', 'city' => 'Nicosia'],
],
'Ansprechpartner statt Firma' => [
"Waldemar Eider\r\nHauptstr.29\r\n67806 Bisterschied", 'Germany',
['company' => null, 'address1' => 'Hauptstr.29', 'address2' => 'Waldemar Eider', 'postal_code' => '67806', 'city' => 'Bisterschied'],
],
'einzeilig mit Kommata' => [
'Wotanstr. 16, 10365 Berlin', 'Germany',
['company' => null, 'address1' => 'Wotanstr. 16', 'postal_code' => '10365', 'city' => 'Berlin'],
],
'einzeilig mit Landesname am Ende' => [
'73 Makarios Avenue, 5th floor, 1070 Nicosia, Cyprus', 'Cyprus',
['company' => null, 'address1' => '73 Makarios Avenue', 'address2' => '5th floor', 'postal_code' => '1070', 'city' => 'Nicosia'],
],
'Schweizer PLZ-Präfix' => [
"Max-Högger-Strasse 6\r\nCH-8048 Zürich", 'Switzerland',
['company' => null, 'address1' => 'Max-Högger-Strasse 6', 'postal_code' => '8048', 'city' => 'Zürich'],
],
'nur Straße ohne PLZ/Ort' => ['Christophstraße 15-17', 'Germany', null],
'Platzhalter N/A' => ['N/A', 'Germany', null],
'leer' => ['', 'Germany', null],
]);