Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions app/contracts/queries/payments_query_filters_contract.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,34 @@ class PaymentsQueryFiltersContract < Dry::Validation::Contract
params do
optional(:invoice_id).maybe(:string, format?: Regex::UUID)
optional(:external_customer_id).maybe(:string)
optional(:currency).maybe(:string, included_in?: Currencies::ACCEPTED_CURRENCIES.keys.map(&:to_s))
optional(:amount_from).maybe(:integer, gteq?: 0, lteq?: 9_223_372_036_854_775_807)
optional(:amount_to).maybe(:integer, gteq?: 0, lteq?: 9_223_372_036_854_775_807)
optional(:receipt_number).maybe(:string, max_size?: 255)
optional(:invoice_number).maybe(:string, max_size?: 255)

optional(:payment_status).maybe do
value(:string, included_in?: Payment::PAYABLE_PAYMENT_STATUS) |
array(:string, included_in?: Payment::PAYABLE_PAYMENT_STATUS)
end
optional(:payment_provider_type).maybe do
value(:string, included_in?: Customer::PAYMENT_PROVIDERS) |
array(:string, included_in?: Customer::PAYMENT_PROVIDERS)
end
optional(:payment_type).maybe do
value(:string, included_in?: Payment::PAYMENT_TYPES.keys.map(&:to_s)) |
array(:string, included_in?: Payment::PAYMENT_TYPES.keys.map(&:to_s))
end
optional(:payable_type).maybe do
value(:string, included_in?: Payment::PAYABLE_TYPES) |
array(:string, included_in?: Payment::PAYABLE_TYPES)
end
end

rule(:amount_from, :amount_to) do
if values[:amount_from] && values[:amount_to] && values[:amount_from] > values[:amount_to]
key(:amount_to).failure("must be greater than or equal to amount_from")
end
end
end
end
28 changes: 24 additions & 4 deletions app/controllers/concerns/payment_index.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,16 +4,36 @@ module PaymentIndex
include Pagination
extend ActiveSupport::Concern

WHITELIST = [
:page, :per_page, :invoice_id, :external_customer_id, :currency, :search_term,
:amount_from, :amount_to, :receipt_number, :invoice_number, :created_at_from, :created_at_to,
:payment_status, :payment_statuses, :payment_provider_type, :payment_type, :payable_type,
{payment_status: [], payment_statuses: [], payment_provider_type: [], payment_type: [], payable_type: []}
].freeze

def payment_index(customer_external_id: nil)
filters = params.permit(:invoice_id)
filters[:external_customer_id] = customer_external_id
result = PaymentsQuery.call(
organization: current_organization,
pagination: {
page: params[:page],
limit: params[:per_page] || PER_PAGE
},
filters: filters
search_term: params[:search_term],
filters: {
invoice_id: params[:invoice_id],
external_customer_id: customer_external_id,
currency: params[:currency],
amount_from: params[:amount_from],
amount_to: params[:amount_to],
receipt_number: params[:receipt_number],
invoice_number: params[:invoice_number],
created_at_from: (Date.iso8601(params[:created_at_from]) if valid_date?(params[:created_at_from])),
created_at_to: (Date.iso8601(params[:created_at_to]) if valid_date?(params[:created_at_to])),
payment_status: params[:payment_status] || params[:payment_statuses],
payment_provider_type: params[:payment_provider_type],
payment_type: params[:payment_type],
payable_type: params[:payable_type]
}
)

if result.success?
Expand All @@ -26,7 +46,7 @@ def payment_index(customer_external_id: nil)
),
::V1::PaymentSerializer,
collection_name: resource_name.pluralize,
meta: pagination_metadata(result.payments)
meta: pagination_metadata(result.payments, params: params.permit(*WHITELIST))
)
)
else
Expand Down
26 changes: 18 additions & 8 deletions app/graphql/resolvers/payments_resolver.rb
Original file line number Diff line number Diff line change
Expand Up @@ -9,31 +9,41 @@ class PaymentsResolver < Resolvers::BaseResolver

description "Query payments of an organization"

argument :amount_from, GraphQL::Types::BigInt, required: false
argument :amount_to, GraphQL::Types::BigInt, required: false
argument :created_at_from, GraphQL::Types::ISO8601Date, required: false
argument :created_at_to, GraphQL::Types::ISO8601Date, required: false
argument :currency, Types::CurrencyEnum, required: false
argument :external_customer_id, ID, required: false
argument :invoice_id, ID, required: false
argument :invoice_number, String, required: false
argument :limit, Integer, required: false
argument :page, Integer, required: false
argument :payable_type, [Types::Payments::PayableTypeEnum], required: false
argument :payment_provider_type, [Types::PaymentProviders::ProviderTypeEnum], required: false
argument :payment_status, [Types::Payments::PayablePaymentStatusEnum], required: false
argument :payment_type, [Types::Payments::PaymentTypeEnum], required: false
argument :receipt_number, String, required: false
argument :search_term, String, required: false

type Types::Payments::Object.collection_type, null: false
type Types::Payments::Object.collection_type(metadata_type: Types::Payments::CollectionMetadata), null: false

def resolve(currency: nil, page: nil, limit: nil, invoice_id: nil, external_customer_id: nil, search_term: nil)
def resolve(page: nil, limit: nil, search_term: nil, **filters)
result = PaymentsQuery.call(
organization: current_organization,
filters: {
invoice_id:,
external_customer_id:,
currency:
},
filters:,
search_term:,
pagination: {
page:,
limit:
}
)

result.payments
return result_error(result) unless result.success?

# Counting every matching payment of a large organization is the slow half of the
# request; cap it like invoices do and let the client show "10,000+".
result.payments.without_count.extend(BaseQuery::CappedTotalCount)
end
end
end
25 changes: 25 additions & 0 deletions app/graphql/types/payments/collection_metadata.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
# frozen_string_literal: true

module Types
module Payments
class CollectionMetadata < GraphqlPagination::CollectionMetadataType
graphql_name "PaymentCollectionMetadata"
description "Pagination metadata for a collection of payments"

field :has_next_page, Boolean, null: false,
description: "True when another page follows, even when `totalCount` is capped"
field :total_count_capped, Boolean, null: false,
description: "True when `totalCount` hit the counting limit and is a lower bound, not the exact total"

def has_next_page
return object.has_next_page? if object.respond_to?(:has_next_page?)

object.current_page < object.total_pages
end

def total_count_capped
object.respond_to?(:capped_total_count?) && object.capped_total_count?
end
end
end
end
11 changes: 11 additions & 0 deletions app/graphql/types/payments/payable_type_enum.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# frozen_string_literal: true

module Types
module Payments
class PayableTypeEnum < Types::BaseEnum
Payment::PAYABLE_TYPES.each do |type|
value type
end
end
end
end
1 change: 1 addition & 0 deletions app/models/payment.rb
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ class Payment < ApplicationRecord
include RansackUuidSearch

PAYABLE_PAYMENT_STATUS = %w[pending processing succeeded failed].freeze
PAYABLE_TYPES = %w[Invoice PaymentRequest].freeze

belongs_to :organization
belongs_to :customer, -> { with_discarded }
Expand Down
2 changes: 2 additions & 0 deletions app/models/payment_method.rb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,8 @@ class PaymentMethod < ApplicationRecord
manual: "manual"
}.freeze

PROVIDER_METHOD_TYPES = %w[card sepa_debit us_bank_account bacs_debit link boleto crypto customer_balance].freeze

validates :provider_method_id, presence: true
validates :is_default, inclusion: {in: [true, false]}

Expand Down
2 changes: 1 addition & 1 deletion app/models/payment_provider_customers/stripe_customer.rb
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ module PaymentProviderCustomers
class StripeCustomer < BaseCustomer
PAYMENT_METHODS_WITH_SETUP = %w[card sepa_debit us_bank_account bacs_debit link boleto].freeze
PAYMENT_METHODS_WITHOUT_SETUP = %w[crypto customer_balance].freeze
PAYMENT_METHODS = (PAYMENT_METHODS_WITH_SETUP + PAYMENT_METHODS_WITHOUT_SETUP).freeze
PAYMENT_METHODS = PaymentMethod::PROVIDER_METHOD_TYPES

validates :provider_payment_methods, presence: true
validate :allowed_provider_payment_methods
Expand Down
94 changes: 89 additions & 5 deletions app/queries/payments_query.rb
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,21 @@

class PaymentsQuery < BaseQuery
Result = BaseResult[:payments]
Filters = BaseFilters[:invoice_id, :external_customer_id, :currency]
Filters = BaseFilters[
:invoice_id,
:external_customer_id,
:currency,
:payment_status,
:amount_from,
:amount_to,
:receipt_number,
:created_at_from,
:created_at_to,
:payment_provider_type,
:invoice_number,
:payment_type,
:payable_type
]

def call
return result unless validate_filters.success?
Expand Down Expand Up @@ -44,7 +58,7 @@ def matching_ids_by_search

branches << search_base.where(id: search_term).select(:id) if search_term.match?(BaseQuery::UUID_REGEX)

if filters.invoice_id.blank?
if filters.invoice_id.blank? && filters.invoice_number.blank?
branches << search_base.where(payable_type: "Invoice", payable_id: matching_invoice_ids).select(:id)
end

Expand Down Expand Up @@ -96,13 +110,22 @@ def apply_filters(scope)
scope = filter_by_invoice(scope) if filters.invoice_id.present?
scope = filter_by_customer(scope) if filters.external_customer_id.present?
scope = filter_by_currency(scope) if filters.currency.present?
scope = with_payment_status(scope) if filters.payment_status.present?
scope = with_amount_range(scope) if filters.amount_from.present? || filters.amount_to.present?
scope = with_receipt_number(scope) if filters.receipt_number.present?
scope = with_created_at_range(scope) if filters.created_at_from.present? || filters.created_at_to.present?
scope = with_payment_provider_type(scope) if filters.payment_provider_type.present?
scope = with_invoice_number(scope) if filters.invoice_number.present?
scope = with_payment_type(scope) if filters.payment_type.present?
scope = with_payable_type(scope) if filters.payable_type.present?
scope
end

def filter_by_customer(scope)
external_customer_id = filters.external_customer_id

scope.joins(:customer).where("customers.external_id = :external_customer_id", external_customer_id:)
# Resolve the customer first so the planner starts from one customer_id instead of a join.
# Discarded customers stay reachable, as with the belongs_to scope on Payment.
customer_id = organization.customers.with_discarded.where(external_id: filters.external_customer_id).pick(:id)
scope.where(customer_id:)
end

def filter_by_invoice(scope)
Expand All @@ -123,4 +146,65 @@ def filter_by_invoice(scope)
def filter_by_currency(scope)
scope.where(amount_currency: filters.currency)
end

def with_payment_status(scope)
scope.where(payable_payment_status: filters.payment_status)
end

def with_amount_range(scope)
scope = scope.where("payments.amount_cents >= ?::bigint", filters.amount_from) if filters.amount_from.present?
scope = scope.where("payments.amount_cents <= ?::bigint", filters.amount_to) if filters.amount_to.present?
scope
end

def with_receipt_number(scope)
# Semi-join on payment_receipts scoped by organization: the receipt is looked up through
# (organization_id, lower(number)) and the outer query becomes a primary-key lookup,
# instead of walking every payment of the organization and probing receipts per row.
receipts = PaymentReceipt.where(organization_id: organization.id)
.where("lower(payment_receipts.number) = lower(?)", filters.receipt_number)
.select(:payment_id)
scope.where(id: receipts)
end

def with_created_at_range(scope)
from = Utils::Datetime.parse_iso8601_date(filters.created_at_from)&.in_time_zone(organization.timezone || "UTC")
to = Utils::Datetime.parse_iso8601_date(filters.created_at_to)&.in_time_zone(organization.timezone || "UTC")
scope = scope.where(created_at: from.beginning_of_day..) if from
scope = scope.where(created_at: ..to.end_of_day) if to
scope
end

def with_payment_provider_type(scope)
types = Array(filters.payment_provider_type).map { |type| "PaymentProviders::#{type.camelize}Provider" }
# Resolve provider ids first, scoped to the organization. Deleted providers are kept because
# historical payments still reference them. An empty list short-circuits to no rows.
provider_ids = PaymentProviders::BaseProvider.unscoped.where(organization_id: organization.id, type: types).pluck(:id)
scope.where(payment_provider_id: provider_ids)
end

def with_invoice_number(scope)
# Resolve the invoice ids first (organization-scoped, case-insensitive), then reach the
# payments through index_payments_on_payable_type_and_payable_id on both payable paths.
# The ids are passed as literals: with sub-selects the planner turns the OR into hashed
# SubPlans evaluated against every payment of the organization. No DISTINCT needed:
# a payment has one payable.
invoice_ids = organization.invoices.where("lower(invoices.number) = lower(?)", filters.invoice_number).pluck(:id)
return scope.none if invoice_ids.empty?

request_ids = PaymentRequest::AppliedInvoice.where(invoice_id: invoice_ids).pluck(:payment_request_id)
scope.where(
"(payments.payable_type = 'Invoice' AND payments.payable_id IN (:invoice_ids)) " \
"OR (payments.payable_type = 'PaymentRequest' AND payments.payable_id IN (:request_ids))",
invoice_ids:, request_ids: request_ids.presence || [nil]
)
end

def with_payment_type(scope)
scope.where(payment_type: filters.payment_type)
end

def with_payable_type(scope)
scope.where(payable_type: filters.payable_type)
end
end
20 changes: 20 additions & 0 deletions qa/payments-filters/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Payments list filter QA

All records used here are synthetic and live in an isolated development organization.

- `api-qa.json`: 75 live HTTP checks across REST, customer-scoped REST and GraphQL. Each successful call asserts the filtered count and every returned ID against the seed manifest. Error cases assert status and validation details.
- Query plans and load tests live in `script/perf/payments_filters/` (synthetic 5M-payment dataset, reproducible); see its README.
- `cross-client-qa.json`: UI, REST, GraphQL, all six SDKs and CLI return the same seven succeeded EUR payments. Customer-scoped SDK calls return two; minimum amount 9223372036854775807 returns two. CLI additionally verifies arrays and saved payment method fallback.

Replay with the API development container running:

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

The seed is development-only and repeatable. It creates 30 payments (29 visible), three customers, stubbed Stripe/GoCardless providers, every status/type/method, receipts, both payable paths, multiple currencies, exact int64 bounds, and organization-timezone boundary timestamps. Local credentials are written only under ignored `tmp/` with mode 0600; do not commit them. The QA script reads those credentials without printing them. Generated IDs change in a new database, so the QA script uses the freshly generated manifest.

The actual `Invoice::VISIBLE_STATUS` includes draft. Existing visibility is preserved: the draft fixture is visible and the open-invoice fixture remains hidden. REST ignores malformed dates; GraphQL's existing ISO8601Date scalar rejects malformed dates before resolution. Existing REST errors use `validation_errors` (plural). Pagination returns a page number in `meta.next_page`, so callers repeat filters on subsequent requests.

Bullet reports pre-existing lazy loads of customer, payable, payment receipt and payment provider in both filtered and unfiltered list responses. These changes add SQL predicates without adding serialized associations or per-row association access. No serializer changes, payment response shape changes, CSV export or data-export code.
Loading
Loading