Skip to content

feat(billing): add SearchOrgInvoices RQL endpoint#1536

Open
paanSinghCoder wants to merge 10 commits intomainfrom
feat/search-org-invoices-rql
Open

feat(billing): add SearchOrgInvoices RQL endpoint#1536
paanSinghCoder wants to merge 10 commits intomainfrom
feat/search-org-invoices-rql

Conversation

@paanSinghCoder
Copy link
Copy Markdown
Contributor

@paanSinghCoder paanSinghCoder commented Apr 13, 2026

Summary

  • add a new SearchOrgInvoices RPC in FrontierService that supports RQLRequest and pagination response

SearchOrgInvoices RQL Support Matrix

Category Supported Notes
org_id Yes (required) Request is org-scoped; validation enforced.
nonzero_amount_only Yes true applies amount > 0 filter.
query.limit Yes Defaults to 50 when missing/invalid.
query.offset Yes Defaults to 0 when missing/invalid.
query.search Yes Applied to searchable invoice fields.
query.sort Yes Works on supported fields below.
query.filters Yes Works on supported fields below.
query.group_by No Explicitly rejected (group_by is not supported).

Filterable / Sortable Fields

Field Type Filter Sort
id string Yes Yes
provider_id string Yes Yes
customer_id string Yes Yes
state string Yes Yes
currency string Yes Yes
amount number Yes Yes
hosted_url string Yes Yes
due_at datetime Yes Yes
effective_at datetime Yes Yes
created_at datetime Yes Yes
period_start_at datetime Yes Yes
period_end_at datetime Yes Yes

Searchable Fields

Field
id
provider_id
state
currency
hosted_url

Operator Support (practical)

Type Common operators
string eq, neq, like, notlike, in, notin, empty, notempty
number eq, neq, gt, gte, lt, lte
datetime eq, neq, gt, gte, lt, lte

Test plan

  • run make proto and verify no generated-file diffs remain
  • run billing API tests for connect handler and repository paths
  • verify existing ListInvoices consumers still behave unchanged
  • exercise SearchOrgInvoices with filters/sort/search/limit/offset

Introduce a new SearchOrgInvoices endpoint with RQL support while keeping ListInvoices behavior unchanged for existing consumers, and wire backend auth/service/repository plus regenerated frontier protobuf bindings.

Made-with: Cursor
@vercel
Copy link
Copy Markdown

vercel bot commented Apr 13, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
frontier Ready Ready Preview, Comment Apr 15, 2026 9:42am

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai bot commented Apr 13, 2026

📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added organization-level invoice search capability with filtering, sorting, and pagination, enabling users to query invoices by ID, state, currency, amount, and timestamps.
  • Tests

    • Added comprehensive unit tests for invoice search functionality, covering validation and error handling scenarios.
  • Chores

    • Updated Proton build dependency.

Walkthrough

Adds an organization-scoped invoice search endpoint and implementation across API handlers, service, repository, models, mocks, tests, and authorization; also updates the Makefile's PROTON_COMMIT constant.

Changes

Cohort / File(s) Summary
Build Configuration
Makefile
Updated PROTON_COMMIT value from ac2df1932fcddcd7f7ff1af0ded07b14d73b781b to 2e33515c45d2bfb63ac8d41f8963129d35a59684.
Data Model
billing/invoice/invoice.go
Added exported types SearchOrgInvoice, SearchOrgInvoicesPagination, and SearchOrgInvoicesResult (RQL tags on search fields and pagination wrapper).
Service Layer
billing/invoice/service.go, billing/invoice/service_search_org_invoices_test.go
Added Repository.SearchOrgInvoices to interface and Service.SearchOrgInvoices method with validation (non-empty customerID, reject GroupBy) plus unit tests for validation and delegation.
API Interface & Mocks
internal/api/v1beta1connect/interfaces.go, internal/api/v1beta1connect/mocks/invoice_service.go
Extended InvoiceService interface with SearchOrgInvoices(... ) (invoice.SearchOrgInvoicesResult, error) and added mock method/expectation helpers.
API Handler & Tests
internal/api/v1beta1connect/billing_invoice.go, internal/api/v1beta1connect/billing_invoice_test.go
Added ConnectHandler.SearchOrgInvoices (org→customer resolution, proto→RQL transform/validate, service call, error mapping, response pagination) and comprehensive handler tests covering validation, customer-not-found, mapping, and pagination.
Repository (Postgres)
internal/store/postgres/billing_invoice_repository.go
Implemented BillingInvoiceRepository.SearchOrgInvoices: builds customer-scoped SQL via frontierutils applying RQL filters/search/sort/pagination, executes COUNT and data queries, transforms rows to models, returns SearchOrgInvoicesResult, and maps RQL errors to invoice.ErrBadInput.
Authorization
pkg/server/connect_interceptors/authorization.go
Added authorization validation entry for /raystack.frontier.v1beta1.FrontierService/SearchOrgInvoices checking UpdatePermission on the organization namespace.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested reviewers

  • AmanGIT07
  • rsbh

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2


ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 29242d54-19b4-4ba1-872f-e89c7f7065a6

📥 Commits

Reviewing files that changed from the base of the PR and between 73227f6 and d42cbf1.

⛔ Files ignored due to path filters (2)
  • proto/v1beta1/frontier.pb.go is excluded by !**/*.pb.go, !proto/**
  • proto/v1beta1/frontierv1beta1connect/frontier.connect.go is excluded by !proto/**
📒 Files selected for processing (8)
  • Makefile
  • billing/invoice/invoice.go
  • billing/invoice/service.go
  • internal/api/v1beta1connect/billing_invoice.go
  • internal/api/v1beta1connect/interfaces.go
  • internal/api/v1beta1connect/mocks/invoice_service.go
  • internal/store/postgres/billing_invoice_repository.go
  • pkg/server/connect_interceptors/authorization.go

Comment thread internal/api/v1beta1connect/billing_invoice.go Outdated
Comment thread internal/store/postgres/billing_invoice_repository.go
@coveralls
Copy link
Copy Markdown

coveralls commented Apr 13, 2026

Coverage Report for CI Build 24447565038

Coverage decreased (-0.01%) to 41.717%

Details

  • Coverage decreased (-0.01%) from the base build.
  • Patch coverage: 82 uncovered changes across 3 files (52 of 134 lines covered, 38.81%).
  • No coverage regressions found.

Uncovered Changes

File Changed Covered %
internal/store/postgres/billing_invoice_repository.go 64 0 0.0%
internal/api/v1beta1connect/billing_invoice.go 55 41 74.55%
pkg/server/connect_interceptors/authorization.go 4 0 0.0%

Coverage Regressions

No coverage regressions found.


Coverage Stats

Coverage Status
Relevant Lines: 36760
Covered Lines: 15335
Line Coverage: 41.72%
Coverage Strength: 11.83 hits per line

💛 - Coveralls

Comment thread billing/invoice/invoice.go
Comment on lines +316 to +324
if customerID == "" {
return nil, 0, errors.New("customer id is required")
}
if rqlQuery == nil {
return nil, 0, fmt.Errorf("%w: query is required", ErrBadInput)
}
if len(rqlQuery.GroupBy) > 0 {
return nil, 0, fmt.Errorf("%w: group_by is not supported", ErrBadInput)
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Validations can go in proto. (Caution: check if rqlQuery is validated there)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

customerID and GroupBy are not present on proto level thus makes sense to keep here.

Remove the query check.

Comment on lines +136 to +141
if rqlQuery.Limit <= 0 {
rqlQuery.Limit = utils.DefaultLimit
}
if rqlQuery.Offset < 0 {
rqlQuery.Offset = utils.DefaultOffset
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

check if this happens automatically in utils

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Replaced the manual check and added utils.AddRQLPaginationInQuery(...)

return nil, 0, fmt.Errorf("%w: %w", invoice.ErrBadInput, err)
}

countQuery, countParams, err := withSort.Select(goqu.COUNT("*")).ToSQL()
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

To get total count, we can use the filter/search applied query

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done. replaced with withSearch

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

♻️ Duplicate comments (1)
internal/store/postgres/billing_invoice_repository.go (1)

314-333: ⚠️ Potential issue | 🟠 Major

Add a deterministic default order before paginating.

Line 330 paginates withSort even when the request does not specify any sort, so page boundaries are nondeterministic and clients can see duplicates/missing invoices across pages.

Suggested fix
 	withSort, err := frontierutils.AddRQLSortInQuery(withSearch, rqlQuery)
 	if err != nil {
 		return nil, 0, fmt.Errorf("%w: %w", invoice.ErrBadInput, err)
 	}
+
+	if len(rqlQuery.Sort) == 0 {
+		withSort = withSort.OrderAppend(
+			goqu.I(TABLE_BILLING_INVOICES+".created_at").Desc(),
+			goqu.I(TABLE_BILLING_INVOICES+".id").Desc(),
+		)
+	}
 
 	countQuery, countParams, err := withSearch.Select(goqu.COUNT("*")).ToSQL()

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 04d90ada-f210-4e7d-a6e5-293e01496ae8

📥 Commits

Reviewing files that changed from the base of the PR and between d42cbf1 and e8c3ce4.

📒 Files selected for processing (3)
  • billing/invoice/service.go
  • internal/api/v1beta1connect/billing_invoice.go
  • internal/store/postgres/billing_invoice_repository.go
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/api/v1beta1connect/billing_invoice.go

Comment thread billing/invoice/service.go Outdated
Comment on lines +309 to +310
if filter.CustomerID == "" {
return nil, errors.New("customer id is required")
return nil, errors.New("customer id not found")
Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot Apr 14, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Use ErrBadInput for missing customer IDs instead of "not found".

Line 310 and Line 317 are validating request input, but they currently return a plain not-found error. That makes invalid requests look like missing resources and is inconsistent with the ErrBadInput handling already used for the other SearchOrgInvoices validation branches.

Suggested fix
 func (s *Service) List(ctx context.Context, filter Filter) ([]Invoice, error) {
 	if filter.CustomerID == "" {
-		return nil, errors.New("customer id not found")
+		return nil, fmt.Errorf("%w: customer id is required", ErrBadInput)
 	}
 	return s.repository.List(ctx, filter)
 }
 
 func (s *Service) SearchOrgInvoices(ctx context.Context, customerID string, nonzeroOnly bool, rqlQuery *rql.Query) ([]Invoice, int64, error) {
 	if customerID == "" {
-		return nil, 0, errors.New("customer id not found")
+		return nil, 0, fmt.Errorf("%w: customer id is required", ErrBadInput)
 	}
 	if rqlQuery == nil {
 		return nil, 0, fmt.Errorf("%w: query is required", ErrBadInput)
 	}

Also applies to: 316-320

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If this is the method used in the older RPC then we can skip changing the error message. For our newer methods we can make the change

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@AmanGIT07, understood! Agreed — List is an existing/older RPC and its error message change can be left as-is. For SearchOrgInvoices (the new method in this PR), the customerID == "" check should be consistent with the other validation branches in the same function. Here's the scoped fix:

 func (s *Service) SearchOrgInvoices(ctx context.Context, customerID string, nonzeroOnly bool, rqlQuery *rql.Query) ([]Invoice, int64, error) {
 	if customerID == "" {
-		return nil, 0, errors.New("customer id not found")
+		return nil, 0, fmt.Errorf("%w: customer id is required", ErrBadInput)
 	}
 	if rqlQuery == nil {
 		return nil, 0, fmt.Errorf("%w: query is required", ErrBadInput)
 	}

This keeps all three validation errors in SearchOrgInvoices consistently wrapped with ErrBadInput, which allows the connect handler to correctly map them to connect.CodeInvalidArgument. No change needed for List.


✏️ Learnings added
Learnt from: AmanGIT07
Repo: raystack/frontier PR: 1536
File: billing/invoice/service.go:309-310
Timestamp: 2026-04-14T21:14:04.534Z
Learning: In the `billing/invoice` package (billing/invoice/service.go, Go), the `List` method is an older RPC whose error messages should not be changed. Only new methods (e.g., `SearchOrgInvoices`) should use `ErrBadInput`-wrapped errors for input validation.

Note: Learnings are effective only in the context of similar code segments. To apply general coding guidelines, please configure review instructions. You can manage existing learnings in the app.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🧹 Nitpick comments (2)
billing/invoice/service_search_org_invoices_test.go (1)

84-93: Prefer ErrorIs for wrapped validation errors.

For the nil-query and group_by cases, asserting only message substrings is brittle. Add assert.ErrorIs(t, err, ErrBadInput) to validate the contract and keep the message checks as secondary.

Proposed test hardening
 	_, _, err = svc.SearchOrgInvoices(context.Background(), "cust-1", true, nil)
 	assert.Error(t, err)
+	assert.ErrorIs(t, err, ErrBadInput)
 	assert.Contains(t, err.Error(), "query is required")
@@
 	_, _, err = svc.SearchOrgInvoices(context.Background(), "cust-1", true, &rql.Query{
 		GroupBy: []string{"state"},
 	})
 	assert.Error(t, err)
+	assert.ErrorIs(t, err, ErrBadInput)
 	assert.Contains(t, err.Error(), "group_by is not supported")
internal/api/v1beta1connect/billing_invoice_test.go (1)

750-754: Avoid early return before mock expectation checks.

Line 753 returns before AssertExpectations, so error-path cases can skip mock verification.

Suggested restructuring
 			got, err := handler.SearchOrgInvoices(context.Background(), tt.request)
 			if tt.wantCode != 0 {
 				assert.Error(t, err)
 				assert.Equal(t, tt.wantCode, connect.CodeOf(err))
-				return
-			}
-
-			assert.NoError(t, err)
-			if tt.assertResp != nil {
-				tt.assertResp(t, got)
+				assert.Nil(t, got)
+			} else {
+				assert.NoError(t, err)
+				if tt.assertResp != nil {
+					tt.assertResp(t, got)
+				}
 			}
 
 			mockInvoiceService.AssertExpectations(t)
 			mockCustomerService.AssertExpectations(t)

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: b292a96f-57e4-4776-9c10-9c5151698a4e

📥 Commits

Reviewing files that changed from the base of the PR and between e8c3ce4 and b8d6070.

📒 Files selected for processing (2)
  • billing/invoice/service_search_org_invoices_test.go
  • internal/api/v1beta1connect/billing_invoice_test.go

Comment thread billing/invoice/service_search_org_invoices_test.go
Comment thread internal/api/v1beta1connect/billing_invoice_test.go
Comment thread internal/api/v1beta1connect/billing_invoice_test.go
return nil, 0, fmt.Errorf("%w: %w", invoice.ErrBadInput, err)
}

withSort, err := frontierutils.AddRQLSortInQuery(withSearch, rqlQuery)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Lets add default sort order in case its not specified

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added createAt default sort

Comment on lines +289 to +302
filterColumns := []string{
"id",
"provider_id",
"customer_id",
"state",
"currency",
"amount",
"hosted_url",
"due_at",
"effective_at",
"created_at",
"period_start_at",
"period_end_at",
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's put these supported columns at top of the file for better visibility. Same for search etc columns

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Done.

}
invoices = append(invoices, inv)
}
return invoices, totalCount, nil
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

why we need to send totalCount separately? It should be embedded in the response itself as per proto definition

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added struct SearchOrgInvoicesPagination and SearchOrgInvoicesResult

@AmanGIT07
Copy link
Copy Markdown
Contributor

@paanSinghCoder Please add some sample request and response

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

♻️ Duplicate comments (1)
internal/api/v1beta1connect/billing_invoice.go (1)

104-135: ⚠️ Potential issue | 🟠 Major

Validate and normalize RQL before GetByOrgID early-return paths.

Right now, invalid RQL can return 200 OK for customer.ErrNotFound, and default pagination is skipped in that branch because validation/defaulting happens later.

🛠️ Proposed fix
 func (h *ConnectHandler) SearchOrgInvoices(ctx context.Context, request *connect.Request[frontierv1beta1.SearchOrgInvoicesRequest]) (*connect.Response[frontierv1beta1.SearchOrgInvoicesResponse], error) {
 	errorLogger := NewErrorLogger()
 	queryMsg := request.Msg.GetQuery()
 	if queryMsg == nil {
 		queryMsg = &frontierv1beta1.RQLRequest{}
 	}

+	rqlQuery, err := utils.TransformProtoToRQL(queryMsg, invoice.SearchOrgInvoice{})
+	if err != nil {
+		return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("failed to read rql query: %v", err))
+	}
+	if err = rql.ValidateQuery(rqlQuery, invoice.SearchOrgInvoice{}); err != nil {
+		return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("failed to validate rql query: %v", err))
+	}
+	if rqlQuery.Limit <= 0 {
+		rqlQuery.Limit = utils.DefaultLimit
+	}
+	if rqlQuery.Offset < 0 {
+		rqlQuery.Offset = utils.DefaultOffset
+	}
+
 	cust, err := h.customerService.GetByOrgID(ctx, request.Msg.GetOrgId())
 	if err != nil {
 		if errors.Is(err, customer.ErrNotFound) {
 			return connect.NewResponse(&frontierv1beta1.SearchOrgInvoicesResponse{
 				Invoices: []*frontierv1beta1.Invoice{},
 				Pagination: &frontierv1beta1.RQLQueryPaginationResponse{
-					Offset:     queryMsg.GetOffset(),
-					Limit:      queryMsg.GetLimit(),
+					Offset:     uint32(rqlQuery.Offset),
+					Limit:      uint32(rqlQuery.Limit),
 					TotalCount: 0,
 				},
 			}), nil
 		}
@@
-	rqlQuery, err := utils.TransformProtoToRQL(queryMsg, invoice.SearchOrgInvoice{})
-	if err != nil {
-		return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("failed to read rql query: %v", err))
-	}
-	if err = rql.ValidateQuery(rqlQuery, invoice.SearchOrgInvoice{}); err != nil {
-		return nil, connect.NewError(connect.CodeInvalidArgument, fmt.Errorf("failed to validate rql query: %v", err))
-	}

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1302b844-eedb-4f66-b9e1-83a8b3ad8be5

📥 Commits

Reviewing files that changed from the base of the PR and between b8d6070 and 9a40f21.

📒 Files selected for processing (9)
  • billing/invoice/invoice.go
  • billing/invoice/service.go
  • billing/invoice/service_search_org_invoices_test.go
  • internal/api/v1beta1connect/billing_invoice.go
  • internal/api/v1beta1connect/billing_invoice_test.go
  • internal/api/v1beta1connect/interfaces.go
  • internal/api/v1beta1connect/mocks/invoice_service.go
  • internal/store/postgres/billing_invoice_repository.go
  • pkg/server/connect_interceptors/authorization.go
✅ Files skipped from review due to trivial changes (2)
  • billing/invoice/service_search_org_invoices_test.go
  • billing/invoice/invoice.go
🚧 Files skipped from review as they are similar to previous changes (5)
  • pkg/server/connect_interceptors/authorization.go
  • internal/store/postgres/billing_invoice_repository.go
  • internal/api/v1beta1connect/billing_invoice_test.go
  • internal/api/v1beta1connect/interfaces.go
  • billing/invoice/service.go

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.

3 participants