Skip to content

billing: list invoices per customer instead of per-invoice Stripe search#3054

Draft
GregorShear wants to merge 1 commit into
masterfrom
greg/billing-invoice-list
Draft

billing: list invoices per customer instead of per-invoice Stripe search#3054
GregorShear wants to merge 1 commit into
masterfrom
greg/billing-invoice-list

Conversation

@GregorShear

Copy link
Copy Markdown
Contributor

Problem

tenant.billing.invoices was returning 429s from Stripe:

error reported by stripe: invalid_request_error (429) ... Request rate limit exceeded.

The Invoice GraphQL type's Stripe-backed fields (amountDue, status, invoicePdf, hostedInvoiceUrl, paymentDetails) each resolve through StripeInvoiceLoader. That loader issued one Stripe Search API call per invoice and fired them all concurrently (try_join_all). The Search API is rate-limited well below the standard read endpoints, so a page with many invoices burst past the limit. try_join_all short-circuits on the first failure, so a single 429 nulled out the entire tenant query.

Fix

Every invoice in a connection belongs to one tenant, hence one Stripe customer. StripeInvoiceLoader now:

  1. Groups the batched keys by customer_id.
  2. Issues a single Invoice::list per customer — the standard list endpoint, far less aggressively rate-limited than Search — paginating with starting_after.
  3. Matches each row to its invoice locally by metadata identity (invoice_type, period_start, period_end).

Drafts remain excluded, preserving the prior -status:"draft" filter. Result values are unchanged; only the Stripe call pattern changes (N concurrent searches → 1 list call per page).

BillingProvider::search_invoices is replaced by list_invoices(customer_id); the Stripe and in-memory impls follow. The billing-integrations publish path keeps its own metadata search and is untouched.

Testing

  • cargo test -p control-plane-api invoice — 4 passed. graphql_billing_invoice_stripe_fields exercises the full resolver path; its snapshot is unchanged.

Not in scope

  • ChargeDataLoader still issues one retrieve_payment_intent per invoice when paymentDetails is requested. That's on the standard API (~100 rps), so far more forgiving, but still an unbounded concurrent burst — straightforward to bound if needed.
  • Persisting the Stripe invoice ID would allow direct Invoice::retrieve instead of a per-customer list, but isn't necessary to resolve the rate limiting.

The invoices GraphQL connection resolves each invoice's Stripe-backed fields (amountDue, status, invoicePdf, hostedInvoiceUrl, paymentDetails) through StripeInvoiceLoader. That loader issued one Stripe Search API call per invoice and fired them concurrently via try_join_all. On pages with many invoices this burst past Stripe's Search rate limit (well below the standard read limit), returning 429 and nulling out the entire tenant query.

Every invoice in a connection belongs to one tenant, hence one Stripe customer. The loader now groups the batched keys by customer and issues a single Invoice::list per customer — the standard list endpoint, which is far less aggressively rate-limited than Search — then matches each row to its invoice locally by metadata identity (invoice_type, period_start, period_end). Drafts are still excluded, preserving the prior -status:draft filter.

Replaces the BillingProvider::search_invoices trait method with list_invoices(customer_id), updates the Stripe and in-memory implementations, and tags the in-memory test invoice with metadata so local matching resolves it. The billing-integrations publish path keeps its own metadata search and is unchanged.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants