From 3320aaaa850730ab839d042f6870444fc9858b72 Mon Sep 17 00:00:00 2001 From: Kevin Adametz Date: Mon, 6 Jul 2026 12:19:50 +0000 Subject: [PATCH] Rechnungsadressen der Bestandskunden aus dem Legacy-Archiv uebernehmen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../Commands/ImportLegacyBillingAddresses.php | 78 +++++++++ app/Services/Billing/LegacyAddressParser.php | 129 ++++++++++++++ .../Billing/LegacyBillingAddressImporter.php | 161 ++++++++++++++++++ config/countries.php | 2 + dev/release/qa-checkliste.md | 1 + .../Live-Deployment-Checkliste (WS-7).md | 7 + .../LegacyBillingAddressImportTest.php | 160 +++++++++++++++++ 7 files changed, 538 insertions(+) create mode 100644 app/Console/Commands/ImportLegacyBillingAddresses.php create mode 100644 app/Services/Billing/LegacyAddressParser.php create mode 100644 app/Services/Billing/LegacyBillingAddressImporter.php create mode 100644 tests/Feature/Billing/LegacyBillingAddressImportTest.php diff --git a/app/Console/Commands/ImportLegacyBillingAddresses.php b/app/Console/Commands/ImportLegacyBillingAddresses.php new file mode 100644 index 00000000..c3016978 --- /dev/null +++ b/app/Console/Commands/ImportLegacyBillingAddresses.php @@ -0,0 +1,78 @@ + 'mysql_presseecho', + 'businessportal24' => 'mysql_businessportal', + ]; + + public function handle(LegacyBillingAddressImporter $importer): int + { + $dryRun = (bool) $this->option('dry-run'); + + if ($dryRun) { + $this->warn('[DRY-RUN] Kein tatsächlicher Schreibvorgang.'); + } + + $countryNamesByPortal = []; + + foreach (self::CONNECTIONS as $portal => $connection) { + $countryNamesByPortal[$portal] = DB::connection($connection) + ->table('country') + ->pluck('name', 'id') + ->map(fn ($name): string => (string) $name) + ->all(); + } + + $report = $importer->import($countryNamesByPortal, $dryRun); + + $this->table( + ['Importiert', 'Adresse vorhanden', 'Nicht parsebar', 'Land unbekannt', 'Quell-Rechnung fehlt'], + [[ + $report['imported'], + $report['skipped_existing'], + count($report['unparseable']), + count($report['unknown_country']), + count($report['missing_invoice']), + ]], + ); + + foreach ($report['unparseable'] as $line) { + $this->warn("Manuell nachpflegen (Adresse nicht parsebar): {$line}"); + } + + foreach ($report['unknown_country'] as $line) { + $this->warn("Manuell nachpflegen (Land nicht zuordenbar): {$line}"); + } + + foreach ($report['missing_invoice'] as $email) { + $this->warn("Manuell prüfen (Quell-Rechnung nicht im Archiv): {$email}"); + } + + return self::SUCCESS; + } +} diff --git a/app/Services/Billing/LegacyAddressParser.php b/app/Services/Billing/LegacyAddressParser.php new file mode 100644 index 00000000..3a228aea --- /dev/null +++ b/app/Services/Billing/LegacyAddressParser.php @@ -0,0 +1,129 @@ +splitIntoLines((string) $blob, $countryName); + + if ($lines === []) { + return null; + } + + // PLZ+Ort von hinten suchen — alles danach wurde bereits als + // Länderzeile aussortiert. + $postalIndex = null; + $postalCode = null; + $city = null; + + for ($i = count($lines) - 1; $i >= 0; $i--) { + if (preg_match(self::POSTAL_CITY_PATTERN, $lines[$i], $matches) === 1) { + $postalIndex = $i; + $postalCode = $matches[1]; + $city = trim($matches[2]); + + break; + } + } + + if ($postalIndex === null || $postalIndex === 0) { + // Keine PLZ+Ort-Zeile oder nichts davor (keine Straße) → manuell. + return null; + } + + $before = array_slice($lines, 0, $postalIndex); + + $company = null; + + foreach ($before as $index => $line) { + if (preg_match(self::COMPANY_PATTERN, $line) === 1) { + $company = $line; + unset($before[$index]); + + break; + } + } + + $before = array_values($before); + + if ($before === []) { + return null; + } + + // Straße = erste Zeile mit Hausnummer (Ziffer); Zusätze wie + // "5th Floor" oder Ansprechpartner wandern nach address2. + $address1 = null; + + foreach ($before as $index => $line) { + if (preg_match('/\d/', $line) === 1) { + $address1 = $line; + unset($before[$index]); + + break; + } + } + + if ($address1 === null) { + $address1 = array_shift($before); + } + + $address2 = $before === [] ? null : implode(', ', $before); + + return [ + 'company' => $company, + 'address1' => $address1, + 'address2' => $address2, + 'postal_code' => $postalCode, + 'city' => $city, + ]; + } + + /** + * Blob in bereinigte Zeilen zerlegen: Zeilenumbrüche, bei einzeiligen + * Blobs Kommata als Trenner; eine reine Länderzeile am Ende (z. B. + * "Cyprus") wird entfernt. + * + * @return list + */ + private function splitIntoLines(string $blob, ?string $countryName): array + { + $lines = preg_split('/\R/u', trim($blob)) ?: []; + $lines = array_values(array_filter(array_map(trim(...), $lines), fn (string $line): bool => $line !== '')); + + if (count($lines) === 1 && str_contains($lines[0], ',') && preg_match(self::POSTAL_CITY_PATTERN, $lines[0]) !== 1) { + $lines = array_values(array_filter(array_map(trim(...), explode(',', $lines[0])), fn (string $line): bool => $line !== '')); + } + + if ($countryName !== null && $lines !== [] + && strcasecmp((string) end($lines), $countryName) === 0) { + array_pop($lines); + } + + return $lines; + } +} diff --git a/app/Services/Billing/LegacyBillingAddressImporter.php b/app/Services/Billing/LegacyBillingAddressImporter.php new file mode 100644 index 00000000..caf818a4 --- /dev/null +++ b/app/Services/Billing/LegacyBillingAddressImporter.php @@ -0,0 +1,161 @@ + 'DE', + 'Austria' => 'AT', + 'Switzerland' => 'CH', + 'Liechtenstein' => 'LI', + 'Luxembourg' => 'LU', + 'Belgium' => 'BE', + 'Netherlands' => 'NL', + 'France' => 'FR', + 'Italy' => 'IT', + 'Spain' => 'ES', + 'Portugal' => 'PT', + 'Poland' => 'PL', + 'Czech Republic' => 'CZ', + 'Denmark' => 'DK', + 'Sweden' => 'SE', + 'Norway' => 'NO', + 'Finland' => 'FI', + 'Ireland' => 'IE', + 'Greece' => 'GR', + 'Hungary' => 'HU', + 'Cyprus' => 'CY', + 'United Kingdom' => 'GB', + 'United States' => 'US', + 'India' => 'IN', + ]; + + public function __construct(private readonly LegacyAddressParser $parser) {} + + /** + * @param array> $countryNamesByPortal Portal → [legacy country_id → englischer Name] + * @return array{imported: int, skipped_existing: int, unparseable: list, unknown_country: list, missing_invoice: list} + */ + public function import(array $countryNamesByPortal, bool $dryRun = false): array + { + $report = [ + 'imported' => 0, + 'skipped_existing' => 0, + 'unparseable' => [], + 'unknown_country' => [], + 'missing_invoice' => [], + ]; + + $optionsByUser = UserPaymentOption::query() + ->where('status', UserPaymentOptionStatus::Grandfathered->value) + ->with('user.billingAddress') + ->get() + ->filter(fn (UserPaymentOption $option): bool => $option->user !== null) + ->groupBy('user_id'); + + foreach ($optionsByUser as $options) { + $user = $options->first()->user; + + if ($user->billingAddress) { + $report['skipped_existing']++; + + continue; + } + + $invoice = $this->latestSourceInvoice($options); + + if (! $invoice) { + $report['missing_invoice'][] = $user->email; + + continue; + } + + $legacyAddress = $invoice->pdf_payload['billing_address'] ?? []; + $portal = $invoice->legacy_portal->value; + $countryName = $countryNamesByPortal[$portal][(int) ($legacyAddress['country_id'] ?? 0)] ?? null; + $countryCode = self::COUNTRY_NAME_TO_ISO[$countryName] ?? null; + + if ($countryCode === null) { + $report['unknown_country'][] = sprintf('%s (country_id %s)', $user->email, $legacyAddress['country_id'] ?? '—'); + + continue; + } + + $parsed = $this->parser->parse($legacyAddress['address'] ?? null, $countryName); + + if ($parsed === null) { + $report['unparseable'][] = sprintf('%s ("%s")', $user->email, str_replace(["\r\n", "\n"], ' / ', trim((string) ($legacyAddress['address'] ?? '')))); + + continue; + } + + if (! $dryRun) { + BillingAddress::query()->create([ + 'user_id' => $user->id, + 'name' => (string) ($legacyAddress['name'] ?? $user->name), + 'company' => $parsed['company'], + 'address1' => $parsed['address1'], + 'address2' => $parsed['address2'], + 'postal_code' => $parsed['postal_code'], + 'city' => $parsed['city'], + 'country_code' => $countryCode, + ]); + } + + $report['imported']++; + } + + return $report; + } + + /** + * Jüngste Quell-Rechnung über alle Vereinbarungen des Users — hat er + * mehrere Pakete (z. B. je Spoke eines), zählt der neueste Adress-Stand. + * + * @param Collection $options + */ + private function latestSourceInvoice(Collection $options): ?LegacyInvoice + { + return $options + ->map(function (UserPaymentOption $option): ?LegacyInvoice { + $conditions = $option->legacy_conditions ?? []; + $number = $conditions['source_invoice_number'] ?? null; + $portal = $conditions['legacy_portal'] ?? null; + + if (! $number || ! $portal) { + return null; + } + + return LegacyInvoice::query() + ->where('legacy_portal', $portal) + ->where('number', $number) + ->first(); + }) + ->filter() + ->sortByDesc(fn (LegacyInvoice $invoice) => [$invoice->invoice_date, $invoice->id]) + ->first(); + } +} diff --git a/config/countries.php b/config/countries.php index 599f3f11..cda47710 100644 --- a/config/countries.php +++ b/config/countries.php @@ -14,5 +14,7 @@ return [ 'FR' => 'Frankreich', 'GB' => 'Vereinigtes Koenigreich', 'US' => 'Vereinigte Staaten', + // Zypern: reale Bestandskunden aus den Altportalen (Adress-Import 06.07.2026). + 'CY' => 'Zypern', ], ]; diff --git a/dev/release/qa-checkliste.md b/dev/release/qa-checkliste.md index c1d07814..174c1e5a 100644 --- a/dev/release/qa-checkliste.md +++ b/dev/release/qa-checkliste.md @@ -38,6 +38,7 @@ Staging-Rehearsal (R6.3) mit echtem Datenbestand wiederholen. - [ ] **Stripe-Checkout End-to-End inkl. Webhook** — Automatisiert: `Billing/CheckoutFlowTest`, `Billing/StripeWebhookProcessingTest`, `CreditTopupTest` (Gutschrift genau einmal). Manuell auf Staging: echter Testmode-Checkout (Karte 4242…), Webhook via `stripe listen`, Buchung erscheint im Panel. - [ ] **Rechnungen**: STR-Kreis (Stripe/Billing-Portal) + MAN-Kreis (`billing:generate-manual-invoices`) — Automatisiert: `Billing/InvoiceNumberGeneratorTest`, `Billing/ManualInvoiceGenerationTest`. Manuell: Rechnung im Stripe-Billing-Portal abrufbar. - [ ] **Grandfathering**: Alt-Abo wird korrekt angezeigt/weiterberechnet, Bestandskunde sieht keinen Buchungs-Hinweis — Automatisiert: `Billing/GrandfatherLegacySubscriptionsTest`, `Billing/HasActiveBookingTest`, `Billing/LegacyBundleTest`. Manuell: mit echtem Grandfather-User nach dem Staging-Import (R6.3). +- [ ] **Rechnungsadressen der Bestandskunden** (Befund 06.07.: ohne Adresse überspringt der MAN-Lauf die Vereinbarung → Bestandsschutz läuft am Periodenende aus) — Automatisiert: `Billing/LegacyBillingAddressImportTest`. Manuell nach Staging-Import: `legacy:import-billing-addresses --dry-run` prüfen, echter Lauf, dann die im Report gelisteten Kunden (leere/unparsebare Alt-Adressen) manuell nachpflegen oder anschreiben. - [ ] **Legacy-Rechnungen im Archiv** (Admin + Customer, PDF) — Automatisiert: `Admin/AdminLegacyInvoiceArchiveTest`, `LegacyInvoiceArchiveCommandTest`. Manuell: PDF-Download eines Altbelegs. - [ ] **Depublikations-Checkout** (35 € netto) — Automatisiert: `Billing/DepublicationTest`. Manuell auf Staging: einmal komplett bis Tombstone. diff --git a/docs/weiteres/Live-Deployment-Checkliste (WS-7).md b/docs/weiteres/Live-Deployment-Checkliste (WS-7).md index 19ec2ad2..bc0d9a1f 100644 --- a/docs/weiteres/Live-Deployment-Checkliste (WS-7).md +++ b/docs/weiteres/Live-Deployment-Checkliste (WS-7).md @@ -123,6 +123,13 @@ php artisan billing:sync-stripe-plans php artisan legacy:grandfather-subscriptions --dry-run php artisan legacy:grandfather-subscriptions +# Rechnungsadressen der Bestandskunden aus dem Archiv übernehmen (Befund +# 06.07.: ohne Adresse überspringt der MAN-Rechnungslauf die Vereinbarung und +# der Bestandsschutz läuft am Periodenende aus). Läuft NACH dem Grandfathering. +# Der Report listet manuell nachzupflegende Kunden (leere/unparsebare Alt-Adressen). +php artisan legacy:import-billing-addresses --dry-run +php artisan legacy:import-billing-addresses + # Favicons für alle Domains generieren php artisan domains:generate-favicons diff --git a/tests/Feature/Billing/LegacyBillingAddressImportTest.php b/tests/Feature/Billing/LegacyBillingAddressImportTest.php new file mode 100644 index 00000000..1689d019 --- /dev/null +++ b/tests/Feature/Billing/LegacyBillingAddressImportTest.php @@ -0,0 +1,160 @@ + [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], +]);