Skip to content

Conversation

@Abdel-Monaam-Aouini
Copy link

Description:
This PR introduces the /transactions/sum endpoint to the v2 API, which calculates and returns the sum of transactions for a specified account, grouped by asset. The implementation includes:

  • Pagination support with configurable page size
  • Filtering by account and optional asset
  • Proper error handling for missing or invalid parameters
  • Comprehensive test coverage for success and error cases

The endpoint follows the API's standard response format with data wrapping and adheres to the project's coding standards.

@Abdel-Monaam-Aouini Abdel-Monaam-Aouini requested a review from a team as a code owner October 7, 2025 11:29
@coderabbitai
Copy link

coderabbitai bot commented Oct 7, 2025

Walkthrough

Adds a new v2 GET /transactions/sum endpoint and related types, implements store SQL to compute per-asset transaction sums (optional time range and asset filter), extends controller/store/mocks with sum APIs and tracing/metrics, updates views/rendering, and adds unit tests.

Changes

Cohort / File(s) Summary
API handler & tests
internal/api/v2/controllers_transactions_sum.go, internal/api/v2/controllers_transactions_sum_test.go
New handler getTransactionsSum and public sumResponse type; query validation (account, optional asset, start/end), ledger controller lookup, calls to controller sum methods (with optional time range), strict big.Int parsing of sums, JSON response; tests for success, pagination, missing account, large results, and helpers.
Routing
internal/api/v2/routes.go
Registers GET /transactions/sum under /transactions, wiring to getTransactionsSum.
Views / Rendering
internal/api/v2/views.go
Adds transaction-sum render type and JSON marshaler producing "sum" as string and honors header-driven big-int-as-string rendering.
Ledger controller interface & implementations
internal/controller/ledger/controller.go, internal/controller/ledger/controller_default.go, internal/controller/ledger/controller_with_traces.go
Adds Controller methods GetTransactionsSum and GetTransactionsSumWithTimeRange; DefaultController and ControllerWithTraces implement them; ControllerWithTraces adds histogram/metrics and tracing for these calls; minor tracing/locking refactor.
Store interface & generated mocks
internal/controller/ledger/store.go, internal/controller/ledger/store_generated_test.go, internal/api/v2/mocks_ledger_controller_test.go
Adds store interface methods TransactionsSum and TransactionsSumWithTimeRange; updates generated mocks and LedgerController mock to include new methods and recorder helpers.
Storage SQL implementation
internal/storage/ledger/resource_transactions_sum.go, internal/storage/ledger/store.go
New exported type TransactionsSum{Asset,Sum} and Store methods TransactionsSum / TransactionsSumWithTimeRange executing grouped SUM SQL with optional time-range filtering; minor receiver rename in GetDB.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant C as Client
  participant R as Router (/transactions/sum)
  participant H as Handler getTransactionsSum
  participant LC as LedgerController
  participant S as Store (SQL)

  C->>R: GET /transactions/sum?account=acct(&asset=ASSET)(&start_time&end_time)
  R->>H: invoke handler
  H->>H: validate params, parse times
  H->>LC: GetTransactionsSumWithTimeRange(ctx, account, start, end)
  alt controller delegates to store
    LC->>S: TransactionsSumWithTimeRange(ledger, account, start, end)
    S-->>LC: []TransactionsSum{Asset, Sum(string)}
  end
  LC-->>H: []TransactionsSum
  H->>H: convert sums -> big.Int, apply asset filter
  H-->>C: 200 OK with JSON array of sumResponse
  alt errors
    H-->>C: 400 (bad params) / 500 (internal)
  end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related PRs

Suggested reviewers

  • flemzord

Poem

I hop through ledgers, counting each carrot and bun,
Per-asset totals gathered, one by one.
With tiny paws I sum what numbers become,
A rabbit’s ledger dance — quick, neat, and fun. 🥕

Pre-merge checks and finishing touches

❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. You can run @coderabbitai generate docstrings to improve docstring coverage.
✅ Passed checks (2 passed)
Check name Status Explanation
Title Check ✅ Passed The title clearly summarizes the addition of the new /transactions/sum endpoint with account-based aggregation in the v2 API, directly matching the primary functionality introduced by the pull request.
Description Check ✅ Passed The description accurately outlines the creation of the /transactions/sum endpoint, its support for pagination, filtering, error handling, and test coverage, all of which correspond to the changes made in the pull request.
✨ Finishing touches
  • 📝 Generate docstrings
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

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

@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

🧹 Nitpick comments (4)
internal/api/v2/controllers_transactions_sum.go (2)

29-31: Consider extracting duplicate map initialization.

The pattern of checking if a key exists and initializing with big.NewInt(0) is duplicated for both source and destination cases.

You could extract this into a helper to reduce duplication:

 func processPostings(account string, txs *bunpaginate.Cursor[ledger.Transaction], assetFilter string) []sumResponse {
 	// Calculate sums per asset
 	assetSums := make(map[string]*big.Int)
+	
+	ensureAsset := func(asset string) {
+		if _, ok := assetSums[asset]; !ok {
+			assetSums[asset] = big.NewInt(0)
+		}
+	}
 
 	for _, tx := range txs.Data {
 		for _, posting := range tx.Postings {
 			if posting.Source == account {
 				// Debit from the account (negative amount)
-				if _, ok := assetSums[posting.Asset]; !ok {
-					assetSums[posting.Asset] = big.NewInt(0)
-				}
+				ensureAsset(posting.Asset)
 				assetSums[posting.Asset] = new(big.Int).Sub(assetSums[posting.Asset], posting.Amount)
 			} else if posting.Destination == account {
 				// Credit to the account (positive amount)
-				if _, ok := assetSums[posting.Asset]; !ok {
-					assetSums[posting.Asset] = big.NewInt(0)
-				}
+				ensureAsset(posting.Asset)
 				assetSums[posting.Asset] = new(big.Int).Add(assetSums[posting.Asset], posting.Amount)
 			}
 		}
 	}

Also applies to: 35-37


103-103: Clarify the comment.

The comment states "The test expects a single response object in an array" but the endpoint actually returns an array of sumResponse objects (potentially multiple items, one per asset).

Consider updating the comment to be more accurate:

-	// The test expects a single response object in an array
+	// Return an array of sumResponse objects (one per asset)
 	if len(response) == 0 {
 		// If no postings match, return an empty array
 		api.Ok(w, []sumResponse{})
 		return
 	}
 	api.Ok(w, response)
internal/api/v2/controllers_transactions_sum_test.go (2)

19-103: Consider expanding test coverage.

The current tests cover the basic happy path and missing parameter validation, which is good. However, consider adding tests for:

  1. Multiple assets in the response
  2. Account with no matching transactions (empty result)
  3. Using the asset filter parameter
  4. Pagination scenarios (though this relates to the pagination issue flagged separately)
  5. Large transaction amounts that test big.Int handling

Example additional test case for multiple assets:

t.Run("success with multiple assets", func(t *testing.T) {
	t.Parallel()
	
	ctrl := gomock.NewController(t)
	mockLedgerController := NewLedgerController(ctrl)
	
	desc := bunpaginate.OrderDesc
	order := bunpaginate.Order(desc)
	expectedQuery := storagecommon.InitialPaginatedQuery[any]{
		PageSize: 15,
		Column:   "timestamp",
		Order:    &order,
		Options: storagecommon.ResourceQuery[any]{
			Expand:  []string{},
			PIT:     nil,
			OOT:     nil,
			Builder: nil,
			Opts:    nil,
		},
	}
	
	mockLedgerController.EXPECT().
		ListTransactions(gomock.Any(), matchPaginatedQuery(expectedQuery)).
		Return(&bunpaginate.Cursor[ledger.Transaction]{
			Data: []ledger.Transaction{
				ledger.NewTransaction().WithPostings(
					ledger.NewPosting("world", "expenses:salary", "USD", big.NewInt(1000)),
				),
				ledger.NewTransaction().WithPostings(
					ledger.NewPosting("world", "expenses:salary", "EUR", big.NewInt(500)),
				),
			},
		}, nil)
	
	server := newTestServer(t, mockLedgerController)
	req, err := http.NewRequest(http.MethodGet, "/transactions/sum?account=expenses:salary&pageSize=15", nil)
	require.NoError(t, err)
	
	rr := httptest.NewRecorder()
	server.ServeHTTP(rr, req)
	
	require.Equal(t, http.StatusOK, rr.Code)
	
	var responseWrapper struct {
		Data []sumResponse `json:"data"`
	}
	err = json.Unmarshal(rr.Body.Bytes(), &responseWrapper)
	require.NoError(t, err)
	
	require.Len(t, responseWrapper.Data, 2)
	// Verify both USD and EUR are present with correct amounts
})

122-129: Consider improving the gomock matcher for better debugging.

The matchPaginatedQuery matcher returns an empty string in the formatter, which makes debugging test failures harder.

Consider returning a more descriptive string:

 func matchPaginatedQuery(expected storagecommon.InitialPaginatedQuery[any]) gomock.Matcher {
 	return gomock.GotFormatterAdapter(
 		gomock.GotFormatterFunc(func(actual interface{}) string {
-			return ""
+			if query, ok := actual.(storagecommon.InitialPaginatedQuery[any]); ok {
+				return fmt.Sprintf("InitialPaginatedQuery{PageSize: %d, Column: %s, Order: %v}", 
+					query.PageSize, query.Column, query.Order)
+			}
+			return fmt.Sprintf("%#v", actual)
 		}),
 		gomock.Eq(expected),
 	)
 }

Don't forget to add the fmt import if you implement this change.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 58a84d9 and 3a484ff.

📒 Files selected for processing (3)
  • internal/api/v2/controllers_transactions_sum.go (1 hunks)
  • internal/api/v2/controllers_transactions_sum_test.go (1 hunks)
  • internal/api/v2/routes.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
internal/api/v2/controllers_transactions_sum.go (4)
internal/posting.go (1)
  • Postings (35-35)
internal/api/common/errors.go (3)
  • ErrValidation (19-19)
  • InternalServerError (68-73)
  • HandleCommonPaginationErrors (57-66)
internal/storage/common/pagination.go (1)
  • InitialPaginatedQuery (9-14)
internal/storage/common/resource.go (1)
  • ResourceQuery (408-414)
internal/api/v2/controllers_transactions_sum_test.go (4)
internal/storage/common/pagination.go (1)
  • InitialPaginatedQuery (9-14)
internal/storage/common/resource.go (1)
  • ResourceQuery (408-414)
internal/transaction.go (1)
  • NewTransaction (227-231)
internal/posting.go (1)
  • NewPosting (26-33)
🔇 Additional comments (2)
internal/api/v2/routes.go (1)

105-105: LGTM!

The route is correctly placed within the /transactions route group and follows the existing naming conventions.

internal/api/v2/controllers_transactions_sum.go (1)

85-85: No action needed: getExpand is defined in internal/api/v2/common.go (line 67).

Copy link

@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 details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 3a484ff and 3d8f6ec.

📒 Files selected for processing (2)
  • internal/api/v2/controllers_transactions_sum.go (1 hunks)
  • internal/api/v2/controllers_transactions_sum_test.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
internal/api/v2/controllers_transactions_sum.go (4)
internal/posting.go (1)
  • Postings (35-35)
internal/api/common/errors.go (3)
  • ErrValidation (19-19)
  • InternalServerError (68-73)
  • HandleCommonPaginationErrors (57-66)
internal/storage/common/pagination.go (1)
  • InitialPaginatedQuery (9-14)
internal/storage/common/resource.go (1)
  • ResourceQuery (408-414)
internal/api/v2/controllers_transactions_sum_test.go (5)
internal/storage/common/pagination.go (1)
  • InitialPaginatedQuery (9-14)
internal/storage/common/resource.go (1)
  • ResourceQuery (408-414)
internal/transaction.go (1)
  • NewTransaction (227-231)
internal/posting.go (1)
  • NewPosting (26-33)
internal/api/v2/routes.go (1)
  • NewRouter (20-121)

Copy link

@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/api/v2/controllers_transactions_sum.go (1)

127-148: Critical: Pagination cursor never applied—loop fetches first page repeatedly.

The loop calls getPaginatedTransactions(w, r, ledgerInstance, defaultPageSize) with the same original request r on every iteration. Since getPaginatedTransactions uses storagecommon.Extract(r, ...) to read the cursor from r.URL.Query(), and r never changes, the cursor is always empty. This causes Extract to use the defaulter every time, fetching the first page repeatedly instead of advancing through pages. The computed sums will be incorrect (only reflecting the first page, possibly counted multiple times).

Based on the signature of bunpaginate.Cursor and the fact that cursor.Next is a string (checked at line 145), you need to either:

  1. Pass the cursor token explicitly instead of relying on Extract reading from the request
  2. Manually construct the paginated query for subsequent iterations using the cursor token

Here's a suggested fix using approach #2:

 func getTransactionsSum(w http.ResponseWriter, r *http.Request) {
 	// Get account from query parameters
 	account := r.URL.Query().Get("account")
 	if account == "" {
 		api.BadRequest(w, common.ErrValidation, errors.New("account parameter is required"))
 		return
 	}
 
 	// Get asset filter if provided
 	assetFilter := r.URL.Query().Get("asset")
 
 	// Get transactions
 	ledgerInstance := common.LedgerFromContext(r.Context())
 	if ledgerInstance == nil {
 		api.InternalServerError(w, r, errors.New("ledger not found in context"))
 		return
 	}
 
 	// Use a reasonable default page size
 	const defaultPageSize = 100
 	assetSums := make(map[string]*big.Int)
-	var cursor *bunpaginate.Cursor[ledger.Transaction]
-	var ok bool
+	
+	// Prepare initial query
+	order := bunpaginate.Order(bunpaginate.OrderDesc)
+	var currentCursor string
+	maxIterations := 10000 // Defensive limit
+	iteration := 0
 
 	// Process all pages of transactions
 	for {
-		cursor, ok = getPaginatedTransactions(w, r, ledgerInstance, defaultPageSize)
-		if !ok {
-			return // Error already handled
+		iteration++
+		if iteration > maxIterations {
+			api.InternalServerError(w, r, errors.New("pagination limit exceeded"))
+			return
 		}
+		
+		var rq storagecommon.PaginatedQuery[any]
+		var err error
+		
+		if currentCursor == "" {
+			// First page - use Extract from request
+			rq, err = storagecommon.Extract[any](r, func() (*storagecommon.InitialPaginatedQuery[any], error) {
+				return &storagecommon.InitialPaginatedQuery[any]{
+					PageSize: defaultPageSize,
+					Column:   "timestamp",
+					Order:    &order,
+					Options: storagecommon.ResourceQuery[any]{
+						Expand: getExpand(r),
+					},
+				}, nil
+			})
+		} else {
+			// Subsequent pages - unmarshal the cursor
+			rq, err = storagecommon.UnmarshalCursor[any](currentCursor)
+		}
+		
+		if err != nil {
+			api.BadRequest(w, common.ErrValidation, err)
+			return
+		}
+		
+		cursor, err := ledgerInstance.ListTransactions(r.Context(), rq)
+		if err != nil {
+			common.HandleCommonPaginationErrors(w, r, err)
+			return
+		}
 
 		// Process the current page of transactions
 		pageSums := processPostings(account, cursor, "") // Don't filter by asset yet
 
 		// Accumulate sums for each asset
 		for _, ps := range pageSums {
 			if _, exists := assetSums[ps.Asset]; !exists {
 				assetSums[ps.Asset] = big.NewInt(0)
 			}
 			assetSums[ps.Asset] = new(big.Int).Add(assetSums[ps.Asset], ps.Sum)
 		}
 
 		// If there are no more pages, break the loop
 		if !cursor.HasMore || cursor.Next == "" {
 			break
 		}
+		
+		// Defensive check: ensure cursor is advancing
+		if cursor.Next == currentCursor {
+			api.InternalServerError(w, r, errors.New("pagination cursor not advancing"))
+			return
+		}
+		
+		currentCursor = cursor.Next
 	}
 
 	// Prepare the response
 	response := make([]sumResponse, 0, len(assetSums))
 	for asset, sum := range assetSums {
 		// Skip if this asset doesn't match the filter (if any)
 		if assetFilter != "" && assetFilter != asset {
 			continue
 		}
 		response = append(response, sumResponse{
 			Account: account,
 			Asset:   asset,
 			Sum:     sum,
 		})
 	}
 
 	// If we have an asset filter but no matching asset, return empty array
 	if assetFilter != "" && len(response) == 0 {
 		api.Ok(w, []sumResponse{})
 		return
 	}
 
 	// Return the response
 	api.Ok(w, response)
 }

Note: You'll need to verify if storagecommon.UnmarshalCursor is the correct function name in your codebase (it's referenced in the Extract function but not shown in the snippets). Adjust the function name and import if needed.

🧹 Nitpick comments (1)
internal/api/v2/controllers_transactions_sum.go (1)

16-20: Consider unexporting sumResponse if not used externally.

The sumResponse type is exported (capitalized) but appears to be used only within this package for the API response. Unless it's intentionally exposed for external use or testing, consider renaming it to sumResponse (lowercase) to limit its scope and follow the principle of least exposure.

Apply this diff if the type doesn't need to be public:

-type sumResponse struct {
+type sumResponse struct {
	Account string   `json:"account"`
	Asset   string   `json:"asset"`
	Sum     *big.Int `json:"sum"`
}
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 3d8f6ec and 237518e.

📒 Files selected for processing (2)
  • internal/api/v2/controllers_transactions_sum.go (1 hunks)
  • internal/api/v2/controllers_transactions_sum_test.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/api/v2/controllers_transactions_sum_test.go
🧰 Additional context used
🧬 Code graph analysis (1)
internal/api/v2/controllers_transactions_sum.go (4)
internal/storage/common/cursor.go (1)
  • Extract (14-28)
internal/storage/common/pagination.go (1)
  • InitialPaginatedQuery (9-14)
internal/storage/common/resource.go (1)
  • ResourceQuery (408-414)
internal/api/common/errors.go (3)
  • ErrValidation (19-19)
  • HandleCommonPaginationErrors (57-66)
  • InternalServerError (68-73)
🔇 Additional comments (3)
internal/api/v2/controllers_transactions_sum.go (3)

22-67: LGTM!

The double-entry bookkeeping logic is correct: debits (source) subtract from the balance and credits (destination) add to it. The use of big.Int for financial calculations and the careful handling of immutability with new(big.Int).Sub/Add are appropriate. Asset filtering logic is also correct.


69-100: LGTM!

The function correctly uses storagecommon.Extract to handle pagination, including cursor tokens from the request, and properly handles errors. The single-page fetch logic is sound.


151-162: LGTM!

The final response preparation correctly filters by asset when a filter is provided, and properly handles the case where no assets match the filter by returning an empty array (lines 165-168).

Copy link

@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 (2)
internal/api/v2/controllers_transactions_sum.go (2)

190-209: Add defensive safeguards against infinite pagination loops.

As noted in previous reviews, the pagination loop lacks protective measures that could prevent infinite loops or excessive resource consumption. Add:

  1. Maximum iteration limit: Cap the number of pages to prevent runaway loops
  2. Cursor advancement check: Verify that the cursor changes between iterations

Add these defensive checks:

 	const defaultPageSize = 100
+	const maxIterations = 10000 // Adjust based on expected maximum pages
 
 	assetSums := make(map[string]*big.Int)
 
 	// Create a new request with the same context to avoid modifying the original
 	req := r.Clone(r.Context())
 
+	iteration := 0
+	previousCursor := ""
+
 	for {
+		iteration++
+		if iteration > maxIterations {
+			api.InternalServerError(w, r, errors.New("pagination limit exceeded"))
+			return
+		}
+
 		cursor, ok := getPaginatedTransactions(w, req, ledgerInstance, defaultPageSize)
 		if !ok {
 			return // Error already handled
 		}
 
 		pageSums := processPostings(account, cursor, "")
 
 		for _, ps := range pageSums {
 			if _, exists := assetSums[ps.Asset]; !exists {
 				assetSums[ps.Asset] = big.NewInt(0)
 			}
 			assetSums[ps.Asset] = new(big.Int).Add(assetSums[ps.Asset], ps.Sum)
 		}
 
 		if !cursor.HasMore || cursor.Next == "" {
 			break
 		}
 
+		// Verify cursor is advancing
+		if cursor.Next == previousCursor {
+			api.InternalServerError(w, r, errors.New("pagination cursor not advancing"))
+			return
+		}
+		previousCursor = cursor.Next
 	}

These safeguards protect against edge cases and make debugging easier if pagination issues arise.


87-163: Critical: Cursor not propagated between pagination iterations.

The cursor propagation is broken due to variable shadowing. Line 87 creates a local clone req := r.Clone(r.Context()) that shadows the parameter r. When the cursor is set on this local clone (line 162), the changes are lost because:

  1. The local req variable is never used again after line 162
  2. On the next iteration in getTransactionsSum, the same unmodified r (now called req in the caller) is passed again
  3. storagecommon.Extract never sees the cursor parameter and always uses the initial query

This causes the pagination loop to repeatedly fetch the same first page, leading to incorrect sums (or potentially an infinite loop).

Fix the cursor propagation by removing the local clone and modifying the parameter directly:

 func getPaginatedTransactions(
 	w http.ResponseWriter,
 	r *http.Request,
 	ledgerInstance ledgercontroller.Controller,
 	pageSize uint64,
 ) (*bunpaginate.Cursor[ledger.Transaction], bool) {
 	order := bunpaginate.Order(bunpaginate.OrderDesc)
 
 	// Extract query parameters
 	queryParams := r.URL.Query()
 	startTime := queryParams.Get("start_time")
 	endTime := queryParams.Get("end_time")
 
-	// Create a new request with the same context to avoid modifying the original
-	req := r.Clone(r.Context())
-
-	rq, err := storagecommon.Extract[any](req, func() (*storagecommon.InitialPaginatedQuery[any], error) {
+	rq, err := storagecommon.Extract[any](r, func() (*storagecommon.InitialPaginatedQuery[any], error) {
 		// Create a slice to hold query conditions
 		var conditions []query.Builder
 
 		// Add date range filters if provided
 		if startTime != "" {
 			t, err := time.Parse(time.RFC3339, startTime)
 			if err != nil {
 				return nil, fmt.Errorf("invalid start_time format: %w", err)
 			}
 			conditions = append(conditions, query.Gte("timestamp", t))
 		}
 
 		if endTime != "" {
 			t, err := time.Parse(time.RFC3339, endTime)
 			if err != nil {
 				return nil, fmt.Errorf("invalid end_time format: %w", err)
 			}
 			conditions = append(conditions, query.Lte("timestamp", t))
 		}
 
 		// For test compatibility, if no conditions, don't set the Builder
 		var builder query.Builder
 		if len(conditions) > 0 {
 			builder = query.And(conditions...)
 		}
 
 		queryOptions := storagecommon.ResourceQuery[any]{
-			Expand:  getExpand(req),
+			Expand:  getExpand(r),
 			Builder: builder,
 		}
 
 		return &storagecommon.InitialPaginatedQuery[any]{
 			PageSize: pageSize,
 			Column:   "timestamp",
 			Order:    &order,
 			Options:  queryOptions,
 		}, nil
 	})
 
 	if err != nil {
 		api.BadRequest(w, common.ErrValidation, err)
 		return nil, false
 	}
 
 	txs, err := ledgerInstance.ListTransactions(r.Context(), rq)
 	if err != nil {
 		common.HandleCommonPaginationErrors(w, r, err)
 		return nil, false
 	}
 
 	if txs.HasMore && txs.Next != "" {
-		q := req.URL.Query()
+		q := r.URL.Query()
 		q.Set("cursor", txs.Next)
-		req.URL.RawQuery = q.Encode()
+		r.URL.RawQuery = q.Encode()
 	}
 
 	return txs, true
 }

This ensures that the cursor is set on the request that will be passed to the next iteration, allowing storagecommon.Extract to properly detect and unmarshal the cursor.

🧹 Nitpick comments (1)
internal/api/v2/controllers_transactions_sum.go (1)

27-67: Consider simplifying asset filtering logic.

The processPostings function supports an optional assetFilter, but it's always called with an empty string (line 196), and the actual filtering happens later in getTransactionsSum (lines 213-215). This creates redundant iteration over the results.

Consider either:

  1. Pass assetFilter directly to processPostings on line 196 to filter during aggregation
  2. Remove the assetFilter parameter from processPostings entirely and keep filtering in getTransactionsSum

Option 1 would be more efficient as it filters during aggregation rather than after.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 5606b0f and 7a75df7.

📒 Files selected for processing (1)
  • internal/api/v2/controllers_transactions_sum.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
internal/api/v2/controllers_transactions_sum.go (5)
internal/posting.go (1)
  • Postings (35-35)
internal/storage/common/cursor.go (1)
  • Extract (14-28)
internal/storage/common/pagination.go (1)
  • InitialPaginatedQuery (9-14)
internal/storage/common/resource.go (1)
  • ResourceQuery (408-414)
internal/api/common/errors.go (3)
  • ErrValidation (19-19)
  • HandleCommonPaginationErrors (57-66)
  • InternalServerError (68-73)
🔇 Additional comments (2)
internal/api/v2/controllers_transactions_sum.go (2)

21-25: LGTM: Well-structured response type.

The sumResponse struct appropriately uses *big.Int for arbitrary-precision arithmetic, which is essential for financial calculations.


211-228: LGTM: Response construction handles edge cases correctly.

The response construction properly handles the asset filter and correctly returns an empty array when no matching assets are found.

Copy link

@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: 0

♻️ Duplicate comments (2)
internal/api/v2/controllers_transactions_sum.go (2)

168-187: Add defensive safeguards against infinite loops.

The pagination loop lacks protections against infinite loops, which could occur if the cursor implementation has bugs or doesn't advance properly. As noted in previous reviews, add:

  1. Maximum iteration limit: Cap the number of pages
  2. Cursor advancement check: Verify that the cursor value changes between iterations

Based on learnings

Apply this diff:

+	const maxIterations = 10000 // Adjust based on expected transaction volume
+	iteration := 0
+	var previousCursor string
+
 	for {
+		iteration++
+		if iteration > maxIterations {
+			api.InternalServerError(w, r, errors.New("pagination limit exceeded"))
+			return
+		}
+
 		cursor, ok := getPaginatedTransactions(w, req, ledgerInstance, defaultPageSize)
 		if !ok {
 			return // Error already handled
 		}

 		pageSums := processPostings(account, cursor, "")

 		for _, ps := range pageSums {
 			if _, exists := assetSums[ps.Asset]; !exists {
 				assetSums[ps.Asset] = big.NewInt(0)
 			}
 			assetSums[ps.Asset] = new(big.Int).Add(assetSums[ps.Asset], ps.Sum)
 		}

 		if !cursor.HasMore || cursor.Next == "" {
 			break
 		}
+
+		// Verify cursor is advancing
+		if cursor.Next == previousCursor {
+			api.InternalServerError(w, r, errors.New("pagination cursor not advancing"))
+			return
+		}
+		previousCursor = cursor.Next
 	}

82-83: Critical: Local clone discards cursor, breaking pagination.

Line 83 creates a new local clone of the request, which means any cursor modifications made at lines 138-140 are lost when the function returns. The outer req in the calling loop (line 169) is never updated with the cursor, so pagination never advances—each iteration re-fetches the same first page.

Remove the local clone and use the passed-in request directly, since the caller already clones it:

-	// Create a new request with the same context to avoid modifying the original
-	req := r.Clone(r.Context())
-
-	rq, err := storagecommon.Extract[any](req, func() (*storagecommon.InitialPaginatedQuery[any], error) {
+	rq, err := storagecommon.Extract[any](r, func() (*storagecommon.InitialPaginatedQuery[any], error) {

Then update all subsequent uses of req to r:

 	queryOptions := storagecommon.ResourceQuery[any]{
-		Expand:  getExpand(req),
+		Expand:  getExpand(r),
 		Builder: builder,
 	}
 	if txs.HasMore && txs.Next != "" {
-		q := req.URL.Query()
+		q := r.URL.Query()
 		q.Set("cursor", txs.Next)
-		req.URL.RawQuery = q.Encode()
+		r.URL.RawQuery = q.Encode()
 	}
🧹 Nitpick comments (1)
internal/api/v2/controllers_transactions_sum.go (1)

25-65: Consider early asset filtering for efficiency.

The function computes sums for all assets and then filters at the end (lines 44-53). Additionally, the asset filter is applied again in getTransactionsSum (lines 191-193). While this works correctly, if an asset filter is provided, filtering postings early could reduce memory usage and improve performance, especially with many assets.

Example optimization:

func processPostings(account string, txs *bunpaginate.Cursor[ledger.Transaction], assetFilter string) []sumResponse {
	assetSums := make(map[string]*big.Int)

	for _, tx := range txs.Data {
		for _, posting := range tx.Postings {
			// Skip postings that don't match the filter
			if assetFilter != "" && posting.Asset != assetFilter {
				continue
			}
			
			if posting.Source == account {
				if _, ok := assetSums[posting.Asset]; !ok {
					assetSums[posting.Asset] = big.NewInt(0)
				}
				assetSums[posting.Asset] = new(big.Int).Sub(assetSums[posting.Asset], posting.Amount)
			} else if posting.Destination == account {
				if _, ok := assetSums[posting.Asset]; !ok {
					assetSums[posting.Asset] = big.NewInt(0)
				}
				assetSums[posting.Asset] = new(big.Int).Add(assetSums[posting.Asset], posting.Amount)
			}
		}
	}

	response := make([]sumResponse, 0, len(assetSums))
	for asset, amount := range assetSums {
		response = append(response, sumResponse{
			Account: account,
			Asset:   asset,
			Sum:     amount,
		})
	}

	return response
}

Then update line 174 to pass the asset filter and remove lines 191-193 in getTransactionsSum.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 7a75df7 and 6447a9c.

📒 Files selected for processing (1)
  • internal/api/v2/controllers_transactions_sum.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
internal/api/v2/controllers_transactions_sum.go (4)
internal/storage/common/cursor.go (1)
  • Extract (14-28)
internal/storage/common/pagination.go (1)
  • InitialPaginatedQuery (9-14)
internal/storage/common/resource.go (1)
  • ResourceQuery (408-414)
internal/api/common/errors.go (2)
  • ErrValidation (19-19)
  • HandleCommonPaginationErrors (57-66)
🔇 Additional comments (2)
internal/api/v2/controllers_transactions_sum.go (2)

19-23: LGTM!

The sumResponse struct is well-defined with appropriate field types, including *big.Int for handling large financial amounts.


189-207: LGTM on response building logic.

The response construction and asset filter application are implemented correctly. The handling of empty results (lines 201-204) ensures proper behavior when no transactions match the filter.

Copy link

@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: 0

🧹 Nitpick comments (2)
internal/api/v2/controllers_transactions_sum_test.go (2)

79-80: Remove debug logging.

Debug logging in tests should be removed or commented out before merging to keep test output clean.

Apply this diff:

-		// Print response body for debugging
-		t.Logf("Response body: %s", body)

119-285: Reduce code duplication in pagination setup.

The test sets up 10 nearly identical page expectations with significant duplication. Consider refactoring using a loop.

Apply this diff to reduce duplication:

-		// First page (transactions 0-99)
-		expectedQuery1 := storagecommon.InitialPaginatedQuery[any]{
-			PageSize: 100,
-			Column:   "timestamp",
-			Order:    &order,
-			Options: storagecommon.ResourceQuery[any]{
-				Expand: []string{},
-			},
-		}
-		mockLedgerController.EXPECT().
-			ListTransactions(gomock.Any(), matchPaginatedQuery(expectedQuery1)).
-			Return(&bunpaginate.Cursor[ledger.Transaction]{
-				Data:    transactions[:100],
-				HasMore: true,
-				Next:    "page2",
-			}, nil)
-
-		// Second page (transactions 100-199)
-		expectedQuery2 := storagecommon.InitialPaginatedQuery[any]{
-			PageSize: 100,
-			Column:   "timestamp",
-			Order:    &order,
-			Options: storagecommon.ResourceQuery[any]{
-				Expand: []string{},
-			},
-		}
-		mockLedgerController.EXPECT().
-			ListTransactions(gomock.Any(), matchPaginatedQuery(expectedQuery2)).
-			Return(&bunpaginate.Cursor[ledger.Transaction]{
-				Data:    transactions[100:200],
-				HasMore: true,
-				Next:    "page3",
-			}, nil)
-
-		// Third page (transactions 200-299)
-		expectedQuery3 := storagecommon.InitialPaginatedQuery[any]{
-			PageSize: 100,
-			Column:   "timestamp",
-			Order:    &order,
-			Options: storagecommon.ResourceQuery[any]{
-				Expand: []string{},
-			},
-		}
-		mockLedgerController.EXPECT().
-			ListTransactions(gomock.Any(), matchPaginatedQuery(expectedQuery3)).
-			Return(&bunpaginate.Cursor[ledger.Transaction]{
-				Data:    transactions[200:300],
-				HasMore: true,
-				Next:    "page4",
-			}, nil)
-
-		// Fourth page (transactions 300-399)
-		expectedQuery4 := storagecommon.InitialPaginatedQuery[any]{
-			PageSize: 100,
-			Column:   "timestamp",
-			Order:    &order,
-			Options: storagecommon.ResourceQuery[any]{
-				Expand: []string{},
-			},
-		}
-		mockLedgerController.EXPECT().
-			ListTransactions(gomock.Any(), matchPaginatedQuery(expectedQuery4)).
-			Return(&bunpaginate.Cursor[ledger.Transaction]{
-				Data:    transactions[300:400],
-				HasMore: true,
-				Next:    "page5",
-			}, nil)
-
-		// Fifth page (transactions 400-499)
-		expectedQuery5 := storagecommon.InitialPaginatedQuery[any]{
-			PageSize: 100,
-			Column:   "timestamp",
-			Order:    &order,
-			Options: storagecommon.ResourceQuery[any]{
-				Expand: []string{},
-			},
-		}
-		mockLedgerController.EXPECT().
-			ListTransactions(gomock.Any(), matchPaginatedQuery(expectedQuery5)).
-			Return(&bunpaginate.Cursor[ledger.Transaction]{
-				Data:    transactions[400:500],
-				HasMore: true,
-				Next:    "page6",
-			}, nil)
-
-		// Sixth page (transactions 500-599)
-		expectedQuery6 := storagecommon.InitialPaginatedQuery[any]{
-			PageSize: 100,
-			Column:   "timestamp",
-			Order:    &order,
-			Options: storagecommon.ResourceQuery[any]{
-				Expand: []string{},
-			},
-		}
-		mockLedgerController.EXPECT().
-			ListTransactions(gomock.Any(), matchPaginatedQuery(expectedQuery6)).
-			Return(&bunpaginate.Cursor[ledger.Transaction]{
-				Data:    transactions[500:600],
-				HasMore: true,
-				Next:    "page7",
-			}, nil)
-
-		// Seventh page (transactions 600-699)
-		expectedQuery7 := storagecommon.InitialPaginatedQuery[any]{
-			PageSize: 100,
-			Column:   "timestamp",
-			Order:    &order,
-			Options: storagecommon.ResourceQuery[any]{
-				Expand: []string{},
-			},
-		}
-		mockLedgerController.EXPECT().
-			ListTransactions(gomock.Any(), matchPaginatedQuery(expectedQuery7)).
-			Return(&bunpaginate.Cursor[ledger.Transaction]{
-				Data:    transactions[600:700],
-				HasMore: true,
-				Next:    "page8",
-			}, nil)
-
-		// Eighth page (transactions 700-799)
-		expectedQuery8 := storagecommon.InitialPaginatedQuery[any]{
-			PageSize: 100,
-			Column:   "timestamp",
-			Order:    &order,
-			Options: storagecommon.ResourceQuery[any]{
-				Expand: []string{},
-			},
-		}
-		mockLedgerController.EXPECT().
-			ListTransactions(gomock.Any(), matchPaginatedQuery(expectedQuery8)).
-			Return(&bunpaginate.Cursor[ledger.Transaction]{
-				Data:    transactions[700:800],
-				HasMore: true,
-				Next:    "page9",
-			}, nil)
-
-		// Ninth page (transactions 800-899)
-		expectedQuery9 := storagecommon.InitialPaginatedQuery[any]{
-			PageSize: 100,
-			Column:   "timestamp",
-			Order:    &order,
-			Options: storagecommon.ResourceQuery[any]{
-				Expand: []string{},
-			},
-		}
-		mockLedgerController.EXPECT().
-			ListTransactions(gomock.Any(), matchPaginatedQuery(expectedQuery9)).
-			Return(&bunpaginate.Cursor[ledger.Transaction]{
-				Data:    transactions[800:900],
-				HasMore: true,
-				Next:    "page10",
-			}, nil)
-
-		// Tenth page (transactions 900-999) - last page
-		expectedQuery10 := storagecommon.InitialPaginatedQuery[any]{
-			PageSize: 100,
-			Column:   "timestamp",
-			Order:    &order,
-			Options: storagecommon.ResourceQuery[any]{
-				Expand: []string{},
-			},
-		}
-		mockLedgerController.EXPECT().
-			ListTransactions(gomock.Any(), matchPaginatedQuery(expectedQuery10)).
-			Return(&bunpaginate.Cursor[ledger.Transaction]{
-				Data:    transactions[900:],
-				HasMore: false,
-			}, nil)
+		// Set up expected query template
+		expectedQuery := storagecommon.InitialPaginatedQuery[any]{
+			PageSize: 100,
+			Column:   "timestamp",
+			Order:    &order,
+			Options: storagecommon.ResourceQuery[any]{
+				Expand: []string{},
+			},
+		}
+
+		// Mock all 10 pages
+		pageConfigs := []struct {
+			startIdx int
+			endIdx   int
+			hasMore  bool
+			next     string
+		}{
+			{0, 100, true, "page2"},
+			{100, 200, true, "page3"},
+			{200, 300, true, "page4"},
+			{300, 400, true, "page5"},
+			{400, 500, true, "page6"},
+			{500, 600, true, "page7"},
+			{600, 700, true, "page8"},
+			{700, 800, true, "page9"},
+			{800, 900, true, "page10"},
+			{900, 1000, false, ""},
+		}
+
+		for _, cfg := range pageConfigs {
+			mockLedgerController.EXPECT().
+				ListTransactions(gomock.Any(), matchPaginatedQuery(expectedQuery)).
+				Return(&bunpaginate.Cursor[ledger.Transaction]{
+					Data:    transactions[cfg.startIdx:cfg.endIdx],
+					HasMore: cfg.hasMore,
+					Next:    cfg.next,
+				}, nil)
+		}
📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 6447a9c and 339b332.

📒 Files selected for processing (1)
  • internal/api/v2/controllers_transactions_sum_test.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (1)
internal/api/v2/controllers_transactions_sum_test.go (4)
internal/storage/common/pagination.go (2)
  • InitialPaginatedQuery (9-14)
  • PaginatedQuery (25-28)
internal/storage/common/resource.go (1)
  • ResourceQuery (408-414)
internal/transaction.go (1)
  • NewTransaction (227-231)
internal/posting.go (1)
  • NewPosting (26-33)
🔇 Additional comments (5)
internal/api/v2/controllers_transactions_sum_test.go (5)

314-327: LGTM!

Error handling test is correctly implemented with appropriate nil controller and response verification.


414-427: LGTM!

The test server setup correctly uses chi router patterns and context injection for the mock controller.


429-448: LGTM!

The custom gomock matcher is properly implemented with appropriate use of reflect.DeepEqual for struct comparison.


450-564: LGTM!

Comprehensive testing of the pagination helper function with proper verification of query parameters using DoAndReturn and appropriate error propagation testing.


119-285: Cursor-based pagination is implemented correctly. getPaginatedTransactions updates the cursor URL parameter when txs.HasMore and txs.Next are set, and getTransactionsSum loops accordingly until no more pages remain.

@flemzord
Copy link
Member

flemzord commented Oct 7, 2025

Hey
Thanks for the PR. Unfortunately, there are critical issues that require a refactor.

🔴 Critical Issues

  1. Pagination bug (lines 164-184)
    The cursor is never applied in the loop → first page fetched repeatedly → incorrect results.

  2. Wrong pattern
    Handler should return http.HandlerFunc and accept paginationConfig (see listTransactions, readVolumes).

  3. Code duplication
    getPaginatedTransactions reimplements existing getPaginatedQuery from common.go → remove it entirely.

  4. Performance
    Loading all transactions in memory is inefficient. For 1M+ transactions this will be very slow.

💡 Solution: SQL Implementation

Use SQL aggregation instead (like /aggregate/balances).

Reference: internal/storage/ledger/resource_aggregated_balances.go

SQL query (using moves table):
SELECT asset,
SUM(CASE WHEN is_source THEN -amount::numeric ELSE amount::numeric END) as sum
FROM moves
WHERE ledger = ? AND accounts_address = ?
GROUP BY asset

Files to create/modify:

  1. New: resource_transactions_sum.go (resource handler)
  2. store.go: Add TransactionsSum() method
  3. controller.go: Add GetTransactionsSum()
  4. controllers_transactions_sum.go: Use getResourceQuery + call controller
  5. views.go: Add renderTransactionSum()

🤔 Architectural Question

Do we really need a dedicated endpoint for this?

I'm not entirely convinced we need /transactions/sum. Maybe we should consider evolving existing filters/endpoints instead (e.g., add aggregation capabilities to existing
endpoints)?

This needs more thought and discussion before we commit to this API design.

@Abdel-Monaam-Aouini
Copy link
Author

Hey Thanks for the PR. Unfortunately, there are critical issues that require a refactor.

🔴 Critical Issues

  1. Pagination bug (lines 164-184)
    The cursor is never applied in the loop → first page fetched repeatedly → incorrect results.
  2. Wrong pattern
    Handler should return http.HandlerFunc and accept paginationConfig (see listTransactions, readVolumes).
  3. Code duplication
    getPaginatedTransactions reimplements existing getPaginatedQuery from common.go → remove it entirely.
  4. Performance
    Loading all transactions in memory is inefficient. For 1M+ transactions this will be very slow.

💡 Solution: SQL Implementation

Use SQL aggregation instead (like /aggregate/balances).

Reference: internal/storage/ledger/resource_aggregated_balances.go

SQL query (using moves table): SELECT asset, SUM(CASE WHEN is_source THEN -amount::numeric ELSE amount::numeric END) as sum FROM moves WHERE ledger = ? AND accounts_address = ? GROUP BY asset

Files to create/modify:

  1. New: resource_transactions_sum.go (resource handler)
  2. store.go: Add TransactionsSum() method
  3. controller.go: Add GetTransactionsSum()
  4. controllers_transactions_sum.go: Use getResourceQuery + call controller
  5. views.go: Add renderTransactionSum()

🤔 Architectural Question

Do we really need a dedicated endpoint for this?

I'm not entirely convinced we need /transactions/sum. Maybe we should consider evolving existing filters/endpoints instead (e.g., add aggregation capabilities to existing endpoints)?

This needs more thought and discussion before we commit to this API design.

Thanks for the review!
I’ve fixed all the points you mentioned.
We actually need this feature in our product, which is why I took the initiative to enhance it and contribute back.

Copy link

@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

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 339b332 and 2533958.

⛔ Files ignored due to path filters (1)
  • docker-compose.yml is excluded by !**/*.yml
📒 Files selected for processing (12)
  • internal/api/v2/controllers_transactions_sum.go (1 hunks)
  • internal/api/v2/controllers_transactions_sum_test.go (1 hunks)
  • internal/api/v2/mocks_ledger_controller_test.go (3 hunks)
  • internal/api/v2/routes.go (2 hunks)
  • internal/api/v2/views.go (10 hunks)
  • internal/controller/ledger/controller.go (3 hunks)
  • internal/controller/ledger/controller_default.go (2 hunks)
  • internal/controller/ledger/controller_with_traces.go (4 hunks)
  • internal/controller/ledger/store.go (2 hunks)
  • internal/controller/ledger/store_generated_test.go (2 hunks)
  • internal/storage/ledger/resource_transactions_sum.go (1 hunks)
  • internal/storage/ledger/store.go (1 hunks)
🚧 Files skipped from review as they are similar to previous changes (1)
  • internal/api/v2/routes.go
🧰 Additional context used
🧬 Code graph analysis (10)
internal/storage/ledger/resource_transactions_sum.go (2)
internal/controller/ledger/store.go (1)
  • Store (28-64)
internal/storage/ledger/store.go (1)
  • Store (22-44)
internal/api/v2/controllers_transactions_sum.go (2)
internal/api/common/errors.go (2)
  • ErrValidation (19-19)
  • InternalServerError (68-73)
internal/storage/ledger/resource_transactions_sum.go (1)
  • TransactionsSum (10-13)
internal/api/v2/mocks_ledger_controller_test.go (2)
internal/api/v1/mocks_ledger_controller_test.go (2)
  • LedgerController (26-30)
  • LedgerControllerMockRecorder (33-35)
internal/storage/ledger/resource_transactions_sum.go (1)
  • TransactionsSum (10-13)
internal/api/v2/controllers_transactions_sum_test.go (4)
internal/api/v2/mocks_ledger_controller_test.go (2)
  • NewLedgerController (39-43)
  • LedgerController (27-31)
internal/storage/ledger/resource_transactions_sum.go (1)
  • TransactionsSum (10-13)
internal/api/v2/routes.go (1)
  • NewRouter (21-122)
internal/storage/common/pagination.go (1)
  • InitialPaginatedQuery (9-14)
internal/controller/ledger/controller_with_traces.go (3)
internal/storage/ledger/resource_transactions_sum.go (1)
  • TransactionsSum (10-13)
internal/tracing/tracing.go (1)
  • TraceWithMetric (100-128)
internal/controller/ledger/controller.go (1)
  • Controller (21-85)
internal/controller/ledger/store.go (1)
internal/storage/ledger/resource_transactions_sum.go (1)
  • TransactionsSum (10-13)
internal/controller/ledger/store_generated_test.go (1)
internal/storage/ledger/resource_transactions_sum.go (1)
  • TransactionsSum (10-13)
internal/controller/ledger/controller.go (1)
internal/storage/ledger/resource_transactions_sum.go (1)
  • TransactionsSum (10-13)
internal/controller/ledger/controller_default.go (2)
internal/controller/system/controller.go (1)
  • DefaultController (55-71)
internal/storage/ledger/resource_transactions_sum.go (1)
  • TransactionsSum (10-13)
internal/api/v2/views.go (3)
internal/transaction.go (2)
  • Transaction (36-50)
  • Transaction (52-59)
internal/volumes.go (3)
  • PostCommitVolumes (118-118)
  • Volumes (12-15)
  • Volumes (40-43)
internal/storage/ledger/resource_transactions_sum.go (1)
  • TransactionsSum (10-13)
🔇 Additional comments (5)
internal/controller/ledger/controller_with_traces.go (5)

8-8: LGTM!

The time import is correctly added to support the *time.Time parameters in GetTransactionsSumWithTimeRange.


47-47: LGTM!

The histogram field follows the established naming convention and is correctly placed with other histogram fields.


153-156: LGTM!

The histogram initialization follows the consistent panic-on-error pattern used throughout this constructor.


476-498: LGTM!

Both methods correctly wrap the underlying controller calls with TraceWithMetric, following the established pattern used by other methods in this file.

Note: Both methods share getTransactionsSumHistogram, which is reasonable since they measure the same logical operation (fetching transaction sums) with different parameters. If you later need to distinguish between these calls in metrics, you could add separate histograms.


500-529: LGTM!

The refactored LockLedger method now correctly follows the same pattern as BeginTX (lines 161-188):

  1. Declares variables in outer scope
  2. Calls TraceWithMetric and assigns wrapped controller to ctrl
  3. Inside the closure, calls underlying.LockLedger to capture db and close in outer scope
  4. Returns the wrapped controller along with the captured db and close functions

The variable reuse (where ctrl is first assigned the underlying controller at line 514, then reassigned the wrapped controller at line 507) follows the established pattern and is correct.

Copy link

@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: 0

🧹 Nitpick comments (2)
internal/api/v2/controllers_transactions_sum.go (1)

59-79: Consider pushing asset filter to the SQL layer for efficiency.

The asset filter is currently applied in-memory after fetching all per-asset sums (lines 63-65). While this works correctly, passing assetFilter down to the store layer would allow the SQL query to filter assets via a WHERE asset = ? clause, reducing both data transfer and processing overhead—especially beneficial for accounts with many distinct assets.

Example changes:

In controllers_transactions_sum.go:

 func getTransactionsSum(w http.ResponseWriter, r *http.Request) {
 	account := r.URL.Query().Get("account")
 	if account == "" {
 		api.BadRequest(w, common.ErrValidation, errors.New("account parameter is required"))
 		return
 	}
 
 	assetFilter := r.URL.Query().Get("asset")
 
 	// Parse time filters
 	startTime, err := getDate(r, "start_time")
 	if err != nil {
 		api.BadRequest(w, common.ErrValidation, fmt.Errorf("invalid start_time: %w", err))
 		return
 	}
 
 	endTime, err := getDate(r, "end_time")
 	if err != nil {
 		api.BadRequest(w, common.ErrValidation, fmt.Errorf("invalid end_time: %w", err))
 		return
 	}
 
 	ledgerInstance := common.LedgerFromContext(r.Context())
 	if ledgerInstance == nil {
 		api.InternalServerError(w, r, errors.New("ledger not found in request context"))
 		return
 	}
 
 	var transactionsSum []ledgerstore.TransactionsSum
-	if startTime == nil && endTime == nil {
-		transactionsSum, err = ledgerInstance.GetTransactionsSum(r.Context(), account)
-	} else {
-		transactionsSum, err = ledgerInstance.GetTransactionsSumWithTimeRange(r.Context(), account, startTime, endTime)
-	}
+	if startTime == nil && endTime == nil {
+		transactionsSum, err = ledgerInstance.GetTransactionsSum(r.Context(), account, assetFilter)
+	} else {
+		transactionsSum, err = ledgerInstance.GetTransactionsSumWithTimeRange(r.Context(), account, assetFilter, startTime, endTime)
+	}
 	if err != nil {
 		api.InternalServerError(w, r, err)
 		return
 	}
 
 	// Convert to response format
 	response := make([]sumResponse, 0, len(transactionsSum))
 	for _, ts := range transactionsSum {
-		// Apply asset filter if provided
-		if assetFilter != "" && assetFilter != ts.Asset {
-			continue
-		}
-
 		// Parse the sum from string to big.Int exactly
 		sum := new(big.Int)
 		if _, ok := sum.SetString(ts.Sum, 10); !ok {
 			api.InternalServerError(w, r, fmt.Errorf("invalid sum format: %s", ts.Sum))
 			return
 		}
 
 		response = append(response, sumResponse{
 			Account: account,
 			Asset:   ts.Asset,
 			Sum:     sum,
 		})
 	}
 
 	api.Ok(w, response)
 }

Then update the store methods in resource_transactions_sum.go to accept and use the asset parameter in the WHERE clause (see corresponding comment on that file).

internal/storage/ledger/resource_transactions_sum.go (1)

19-31: Consider adding asset filter parameter for SQL-level filtering.

To complement the suggested refactor in the API handler, this method could accept an optional asset parameter and add it to the WHERE clause when provided. This would allow the database to filter by asset, reducing data transfer and processing overhead.

Example implementation:

-func (s *Store) TransactionsSumWithTimeRange(ctx context.Context, ledger string, account string, startTime, endTime *time.Time) ([]TransactionsSum, error) {
+func (s *Store) TransactionsSumWithTimeRange(ctx context.Context, ledger string, account string, asset string, startTime, endTime *time.Time) ([]TransactionsSum, error) {
 	whereClause := "ledger = ? AND accounts_address = ?"
 	args := []any{ledger, account}
 
+	if asset != "" {
+		whereClause += " AND asset = ?"
+		args = append(args, asset)
+	}
+
 	if startTime != nil {
 		whereClause += " AND effective_date >= ?"
 		args = append(args, startTime)
 	}
 
 	if endTime != nil {
 		whereClause += " AND effective_date <= ?"
 		args = append(args, endTime)
 	}
 
 	query := fmt.Sprintf("SELECT asset, SUM(CASE WHEN is_source THEN -amount::numeric ELSE amount::numeric END)::text as sum FROM %s WHERE %s GROUP BY asset", s.GetPrefixedRelationName("moves"), whereClause)
 
 	rows, err := s.db.QueryContext(ctx, query, args...)
 	if err != nil {
 		return nil, err
 	}
 	defer rows.Close()
 
 	var results []TransactionsSum
 	for rows.Next() {
 		var asset string
 		var sum string
 		if err := rows.Scan(&asset, &sum); err != nil {
 			return nil, err
 		}
 		results = append(results, TransactionsSum{
 			Asset: asset,
 			Sum:   sum,
 		})
 	}
 
 	if err := rows.Err(); err != nil {
 		return nil, err
 	}
 
 	return results, nil
 }

Also update the TransactionsSum wrapper method:

-func (s *Store) TransactionsSum(ctx context.Context, ledger string, account string) ([]TransactionsSum, error) {
-	return s.TransactionsSumWithTimeRange(ctx, ledger, account, nil, nil)
+func (s *Store) TransactionsSum(ctx context.Context, ledger string, account string, asset string) ([]TransactionsSum, error) {
+	return s.TransactionsSumWithTimeRange(ctx, ledger, account, asset, nil, nil)
 }

Note: This refactor would require updating all call sites, including controller methods and their corresponding interface definitions.

📜 Review details

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Disabled knowledge base sources:

  • Linear integration is disabled by default for public repositories

You can enable these sources in your CodeRabbit configuration.

📥 Commits

Reviewing files that changed from the base of the PR and between 2533958 and a4cf8ea.

📒 Files selected for processing (2)
  • internal/api/v2/controllers_transactions_sum.go (1 hunks)
  • internal/storage/ledger/resource_transactions_sum.go (1 hunks)
🧰 Additional context used
🧬 Code graph analysis (2)
internal/storage/ledger/resource_transactions_sum.go (2)
internal/controller/ledger/store.go (1)
  • Store (28-64)
internal/storage/ledger/store.go (1)
  • Store (22-44)
internal/api/v2/controllers_transactions_sum.go (2)
internal/api/common/errors.go (2)
  • ErrValidation (19-19)
  • InternalServerError (68-73)
internal/storage/ledger/resource_transactions_sum.go (1)
  • TransactionsSum (10-13)
🔇 Additional comments (5)
internal/api/v2/controllers_transactions_sum.go (2)

20-82: Excellent refactor—SQL aggregation resolves previous pagination concerns.

The switch from in-memory pagination to SQL-level aggregation (via GetTransactionsSum/GetTransactionsSumWithTimeRange) addresses the critical pagination bugs flagged in earlier reviews. The handler now correctly computes sums across all matching transactions without the risk of incomplete results or infinite loops.


68-72: Sum parsing error handling is correct.

The strict parsing of sum strings to big.Int with error handling ensures invalid formats are caught and reported appropriately as 500 Internal Server Error.

internal/storage/ledger/resource_transactions_sum.go (3)

19-59: SQL aggregation implementation is efficient and correct.

The use of SQL GROUP BY with SUM(CASE WHEN ...) properly aggregates transaction amounts at the database level, avoiding the memory and performance issues of the previous in-memory approach. The ledger filter is now correctly applied (line 20), resolving the compile error flagged in past reviews.


35-39: Proper resource cleanup for database query.

The defer rows.Close() ensures the result set is properly closed even if an error occurs during scanning, preventing resource leaks.


33-33: No action needed: effective_date is the intended date column. The moves table defines both effective_date and insertion_date (no generic timestamp field), so default filtering on effective_date is correct.

@Abdel-Monaam-Aouini
Copy link
Author

i'll close it, i need more context to understand the pattern here :)

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