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>
160 lines
6.4 KiB
PHP
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],
|
|
]);
|