Offene Rechnungen bis zum Ende blaettern (Codex P2)

main
nexxo 2026-07-31 20:58:21 +02:00
parent 0944de7cfa
commit d6fd760a8b
2 changed files with 55 additions and 11 deletions

View File

@ -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'],

View File

@ -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);
});