diff --git a/app/Services/Stripe/HttpStripeClient.php b/app/Services/Stripe/HttpStripeClient.php index f7c287f..1a09d85 100644 --- a/app/Services/Stripe/HttpStripeClient.php +++ b/app/Services/Stripe/HttpStripeClient.php @@ -586,17 +586,34 @@ class HttpStripeClient implements StripeClient public function openInvoices(string $customerId): array { - $invoices = $this->request() - ->get($this->url('invoices'), [ - 'customer' => $customerId, - // Finalisiert und unbezahlt. Ohne den Filter käme die ganze - // Historie zurück, und neben längst beglichenen Rechnungen - // stünde ein Bezahlknopf. - 'status' => 'open', - 'limit' => 100, - ]) - ->throw() - ->json('data', []); + $invoices = []; + $after = null; + + // Bis zum Ende geblättert, wie invoiceLines() darunter. Stripe deckelt + // eine Seite bei hundert, und dieses Projekt hat die abgeschnittene + // erste Seite schon einmal bezahlt: der alte Hetzner-Client fand einen + // Eintrag jenseits der hundert nicht und legte einen zweiten an. Hier + // ginge es ums Geld — eine ältere offene Rechnung auf Seite zwei wäre + // für den Kunden nicht bezahlbar, weil sie ihm nie angezeigt wird. + // (Codex-Review 2026-07-31, P2.) + do { + $page = $this->request() + ->get($this->url('invoices'), array_filter([ + 'customer' => $customerId, + // Finalisiert und unbezahlt. Ohne den Filter käme die ganze + // Historie zurück, und neben längst beglichenen Rechnungen + // stünde ein Bezahlknopf. + 'status' => 'open', + 'limit' => 100, + 'starting_after' => $after, + ])) + ->throw() + ->json(); + + $data = $page['data'] ?? []; + $invoices = array_merge($invoices, $data); + $after = $data === [] ? null : (string) end($data)['id']; + } while (($page['has_more'] ?? false) && $after !== null); return array_map(fn (array $invoice) => [ 'id' => (string) $invoice['id'], diff --git a/tests/Feature/Billing/OpenInvoiceClientTest.php b/tests/Feature/Billing/OpenInvoiceClientTest.php index 020b0ad..6b24ab9 100644 --- a/tests/Feature/Billing/OpenInvoiceClientTest.php +++ b/tests/Feature/Billing/OpenInvoiceClientTest.php @@ -62,3 +62,30 @@ it('reports a successful charge', function () { expect(app(HttpStripeClient::class)->payInvoice('in_1')['paid'])->toBeTrue(); }); + +it('pages to the end instead of stopping at the first hundred', function () { + // Dieselbe Falle, die diesem Projekt schon einmal teuer kam: der alte + // Hetzner-Client las nur die erste Seite und legte deshalb einen zweiten + // A-Eintrag an. `invoiceLines()` direkt daneben blättert längst bis zum + // Ende. Hier gilt es dem Geld: eine ältere offene Rechnung, die auf Seite + // zwei liegt, wäre für den Kunden schlicht nicht bezahlbar. + $seite = fn (int $von, bool $weiter) => Http::response([ + 'has_more' => $weiter, + 'data' => collect(range($von, $von + 99)) + ->map(fn (int $i) => [ + 'id' => 'in_'.$i, 'number' => 'R-'.$i, + 'amount_due' => 100, 'currency' => 'eur', 'created' => 1750000000, + ])->all(), + ]); + + // Nach der Seite gefragt wird über `starting_after`, nicht über eine + // Seitenzahl — daran wird hier auch unterschieden, sonst beantwortete die + // Attrappe beide Aufrufe gleich und der Test liefe gegen den Fehler grün. + Http::fake(function ($request) use ($seite) { + parse_str((string) parse_url($request->url(), PHP_URL_QUERY), $query); + + return isset($query['starting_after']) ? $seite(101, false) : $seite(1, true); + }); + + expect(app(HttpStripeClient::class)->openInvoices('cus_42'))->toHaveCount(200); +});