Skip to content

feat(payments): add list filters to payments (REST + GraphQL) - #6325

Draft
sarkissianraffi wants to merge 9 commits into
mainfrom
feat/payments-list-filters
Draft

sarkissianraffi wants to merge 9 commits into
mainfrom
feat/payments-list-filters

Conversation

@sarkissianraffi

@sarkissianraffi sarkissianraffi commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Payments can now be filtered consistently through REST, customer-scoped REST and GraphQL. Exact invoice filters cover both payable paths without duplicate rows or incorrect pagination counts; REST also forwards currency and search.

Roadmap Task

User-provided /payments filtering specification; no tracking ticket supplied.

Context

The payments page needs the same filter capabilities as the public API while retaining existing payment visibility, ordering and response objects.

Description

REST GraphQL Semantics
payment_status / payment_statuses paymentStatus OR across pending, processing, succeeded, failed; singular REST alias takes precedence
amount_from, amount_to amountFrom, amountTo Inclusive nonnegative int64 cents; reject reversed bounds
receipt_number receiptNumber Exact case-insensitive receipt number, max 255
created_at_from, created_at_to createdAtFrom, createdAtTo Inclusive organization-timezone dates
payment_provider_type paymentProviderType OR across the six configured provider types
currency currency Payment amount currency; now forwarded by REST
invoice_number invoiceNumber Exact case-insensitive number, max 255; matches Invoice and any invoice in a PaymentRequest
external_customer_id externalCustomerId Existing exact customer filter
payment_type paymentType manual / provider
payable_type payableType Invoice / PaymentRequest
search_term searchTerm Existing search now exposed on REST; exact invoice/customer filters skip redundant search branches

All filters AND together; array entries OR together. Existing page, per_page, invoice_id and GraphQL pagination/search arguments remain supported.

Adds chainable query predicates, contract validation, shared REST concern wiring, GraphQL arguments and regenerated schema.graphql/schema.json. Provider method vocabulary lives on PaymentMethod and the Stripe customer constant remains an alias. No distinct or serializer changes.

Verification

  • GitHub: all 13 checks pass, including all ten RSpec shards, migrations, lint and frontend schema compatibility.
  • 318 focused RSpec examples passed at implementation time; after the performance pass, 290 payments examples (query, contract, REST, customer REST, resolver) pass, plus a SQL tripwire spec.
  • RuboCop: 15 changed Ruby files clean.
  • 75 live REST/customer REST/GraphQL assertions pass (re-run after the performance pass), including 422 cases, exact text, timezone boundary days, PaymentRequest invoices, combined search/filters, pagination and full int64 bounds.
  • Full frontend regression run: 12,772 tests across 1,023 suites pass under TZ=UTC, matching CI; changed line/branch coverage 96.38%.
  • 47 browser checks pass, with every returned ID and count compared to REST and the seed manifest. UI, all six SDKs and CLI return the same seven succeeded EUR payments.

API and client QA, 75 HTTP assertions, cross-client IDs, UI QA and recording

Reproduce on a running development API (synthetic fixtures, no gateway calls):

lago exec api bundle exec rails runner script/seed_payments_filters.rb
python3 script/qa_payments_filters.py

Seed script: 30 payments / 29 visible, three customers, stub Stripe and GoCardless, all statuses/types/methods, receipts, two-invoice PaymentRequest, two currencies, dates at organization-timezone boundaries, zero and amounts through 9223372036854775807. Credentials stay in ignored mode-0600 files.

Performance

All shipped filters meet the internal latency targets on a synthetic dataset of ~5M payments for one organization (plus 50 smaller organizations), PostgreSQL 15, 20 concurrent clients. Plans, dataset generator and load test are reproducible from script/perf/payments_filters/ (see its README). Performance analysis: internal document "Payments list filters: performance analysis" (Raffi; not public).

Query rewrites in this PR (from the plans):

  • receipt_number and invoice_number resolve the receipt / invoice ids first through organization-scoped sub-selects, then reach payments by primary key / (payable_type, payable_id).
  • payment_provider_type resolves provider ids in Ruby, scoped to the organization.
  • external_customer_id resolves the customer id first.

Rollout order: #6341 (indexes, CREATE INDEX CONCURRENTLY, migration-only) first, then this PR. Confirm pg_stat_user_indexes.idx_scan is non-zero on the new indexes once the filters ship.

Not shipped for performance reasons

  • payment_method_type (REST payment_method_type[], GraphQL paymentMethodType): counting a rare method type has to walk the whole organization while the saved-method fallback exists, and no index changes that. Removed in one commit (feat(payments): drop the payment_method_type list filter), so it can come back once the method type is a real, indexed column. Same removal on lago-front and lago-openapi; the SDK clients and CLI must be regenerated from the updated spec.

Known, pre-existing

REST meta.total_count runs an unbounded COUNT(*) with the per-row invoice visibility check on every page; on very large organizations that count, not the filters, dominates response time. GraphQL now uses the capped count invoices already use (PaymentCollectionMetadata with totalCountCapped / hasNextPage, the UI shows "10,000+"). The REST count strategy is a follow-up.

Compatibility notes

  • The current Invoice::VISIBLE_STATUS includes draft. Preserve the existing base scope and visibility condition; the draft fixture remains visible and an open-invoice fixture stays hidden.
  • REST malformed dates are dropped; GraphQL ISO8601Date rejects malformed dates before resolution, preserving scalar behavior.
  • The existing GraphQL PaymentMethodTypeEnum means manual/provider. The new provider-method enum is PaymentProviderMethodTypeEnum; payment type keeps its existing enum.
  • Existing REST error code is validation_errors (plural). meta.next_page is a page number, not a URL; clients repeat filters without changing response metadata.

Not in scope: serializer/payment response changes or CSV/data export.

Related PRs

Merge order: #6341 (indexes) deployed first, then API, then front; OpenAPI before client/CLI releases. JavaScript CI uses the pinned feature spec during that rollout.

Raffi added 3 commits September 7, 2026 14:57
## Context

Payments list filters need a shared development dataset for REST and browser QA.

## Description

Seed an isolated organization with payment variants, boundary amounts, dates, and an expected-record manifest. Store disposable credentials only in ignored local files.
## Context

Finance needs the same payment filters through both list APIs, including exact invoice and receipt matches and amounts beyond JavaScript integer precision.

## Description

Add composable validated payment filters and expose currency and search on REST. Preserve payment responses, visibility, ordering, and pagination. Generate the GraphQL schemas and add replayable HTTP QA and a 100,000-payment benchmark.
## Context

Payment filters need reproducible evidence across the API, UI and clients.

## Description

Record HTTP assertions, cross-client comparisons and query plans on synthetic development data. Document replay commands and existing compatibility constraints.
Raffi added 6 commits September 8, 2026 20:55
- receipt_number and invoice_number resolve the receipt / invoice ids first
  through organization-scoped lookups, then reach payments by primary key or
  (payable_type, payable_id); no function on the joined column, no DISTINCT
- payment_provider_type resolves provider ids in Ruby, scoped to the organization
- payment_method_type evaluates the jsonb type and the saved-method fallback as
  two plain predicates instead of a COALESCE across a LEFT JOIN, and no longer
  matches soft-deleted payment methods
- external_customer_id resolves the customer id first instead of joining customers
- spec fixtures use customers and providers of the payment's organization
- SQL tripwire spec: no DISTINCT, no function on indexed payments columns, no joins
  on the lookup tables
Removed for performance reasons: the count of a rare method type walks the
whole organization and cannot be served by an index while the saved-method
fallback branch exists. The way back is a denormalised payments column with
its own partial index. Removed from the query, contract, REST whitelist,
GraphQL resolver and enum, regenerated schema, specs and the QA script.
The superseded 100k-row benchmark script and its EXPLAIN dump are replaced
by script/perf/payments_filters/.
script/perf/payments_filters/: synthetic dataset generator (one 5M-payment
organization plus 50 smaller ones, illustrative skews), case matrix, plan
capture through PaymentsQuery (list and COUNT(*), EXPLAIN ANALYZE BUFFERS,
median of 3), load test (HTTP and DB-only), before/after scoreboard, and the
read-only replica queries for the production-scale inputs. Plans captured on
the synthetic dataset before and after the rewrites and indexes are committed
with identifiers redacted; the internal performance document quotes them.
Plans, DB-only and HTTP load-test results captured on the synthetic dataset
after the rewrites and indexes, plus the before/after comparison.
Counting every matching payment of a large organization is the slow half of
the payments query. Reuse BaseQuery::CappedTotalCount and expose
PaymentCollectionMetadata with totalCountCapped and hasNextPage, exactly as
the invoices collection does, so the client shows "10,000+" and paginates
on hasNextPage.
lpad truncates longer strings, so customers 1000+ shared the slugs (and
receipt numbers) of customers 1-999. Pad like the receipt trigger does.
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.

1 participant