From 38b91ca53dea08a64ed374cbd635450065eb6824 Mon Sep 17 00:00:00 2001 From: Marwen Abid Date: Thu, 12 Dec 2024 16:32:36 -0800 Subject: [PATCH] SDP-1391 Export Payments with Filtering (#493) --- internal/data/payments.go | 15 +- internal/data/payments_test.go | 36 ++--- .../serve/httphandler/assets_handler_test.go | 2 +- internal/serve/httphandler/export_handler.go | 72 +++++++++ .../serve/httphandler/export_handler_test.go | 143 ++++++++++++++++++ .../serve/httphandler/payments_handler.go | 2 +- internal/serve/serve.go | 1 + internal/serve/serve_test.go | 1 + .../disbursement_management_service.go | 2 +- 9 files changed, 248 insertions(+), 26 deletions(-) diff --git a/internal/data/payments.go b/internal/data/payments.go index b0ad6ad53..754c63a0a 100644 --- a/internal/data/payments.go +++ b/internal/data/payments.go @@ -252,7 +252,7 @@ func (p *PaymentModel) Count(ctx context.Context, queryParams *QueryParams, sqlE JOIN receiver_wallets rw on rw.receiver_id = p.receiver_id AND rw.wallet_id = w.id ` - query, params := newPaymentQuery(baseQuery, queryParams, false, sqlExec) + query, params := newPaymentQuery(baseQuery, queryParams, sqlExec, QueryTypeCount) err := sqlExec.GetContext(ctx, &count, query, params...) if err != nil { @@ -262,10 +262,10 @@ func (p *PaymentModel) Count(ctx context.Context, queryParams *QueryParams, sqlE } // GetAll returns all PAYMENTS matching the given query parameters. -func (p *PaymentModel) GetAll(ctx context.Context, queryParams *QueryParams, sqlExec db.SQLExecuter) ([]Payment, error) { +func (p *PaymentModel) GetAll(ctx context.Context, queryParams *QueryParams, sqlExec db.SQLExecuter, queryType QueryType) ([]Payment, error) { payments := []Payment{} - query, params := newPaymentQuery(basePaymentQuery, queryParams, true, sqlExec) + query, params := newPaymentQuery(basePaymentQuery, queryParams, sqlExec, queryType) err := sqlExec.SelectContext(ctx, &payments, query, params...) if err != nil { @@ -620,7 +620,7 @@ func (p *PaymentModel) GetByIDs(ctx context.Context, sqlExec db.SQLExecuter, pay } // newPaymentQuery generates the full query and parameters for a payment search query -func newPaymentQuery(baseQuery string, queryParams *QueryParams, paginated bool, sqlExec db.SQLExecuter) (string, []interface{}) { +func newPaymentQuery(baseQuery string, queryParams *QueryParams, sqlExec db.SQLExecuter, queryType QueryType) (string, []interface{}) { qb := NewQueryBuilder(baseQuery) if queryParams.Filters[FilterKeyStatus] != nil { if statusSlice, ok := queryParams.Filters[FilterKeyStatus].([]PaymentStatus); ok { @@ -640,10 +640,13 @@ func newPaymentQuery(baseQuery string, queryParams *QueryParams, paginated bool, if queryParams.Filters[FilterKeyCreatedAtBefore] != nil { qb.AddCondition("p.created_at <= ?", queryParams.Filters[FilterKeyCreatedAtBefore]) } - if paginated { - qb.AddSorting(queryParams.SortBy, queryParams.SortOrder, "p") + if queryType == QueryTypeSelectPaginated { qb.AddPagination(queryParams.Page, queryParams.PageLimit) } + + if queryType == QueryTypeSelectAll || queryType == QueryTypeSelectPaginated { + qb.AddSorting(queryParams.SortBy, queryParams.SortOrder, "p") + } query, params := qb.Build() return sqlExec.Rebind(query), params } diff --git a/internal/data/payments_test.go b/internal/data/payments_test.go index d7ff29dd4..aaaa3f986 100644 --- a/internal/data/payments_test.go +++ b/internal/data/payments_test.go @@ -249,7 +249,7 @@ func Test_PaymentModelGetAll(t *testing.T) { paymentModel := PaymentModel{dbConnectionPool: dbConnectionPool} t.Run("returns empty list when no payments exist", func(t *testing.T) { - payments, errPayment := paymentModel.GetAll(ctx, &QueryParams{}, dbConnectionPool) + payments, errPayment := paymentModel.GetAll(ctx, &QueryParams{}, dbConnectionPool, QueryTypeSelectPaginated) require.NoError(t, errPayment) assert.Equal(t, 0, len(payments)) }) @@ -302,7 +302,7 @@ func Test_PaymentModelGetAll(t *testing.T) { t.Run("returns payments successfully", func(t *testing.T) { params := QueryParams{SortBy: DefaultPaymentSortField, SortOrder: DefaultPaymentSortOrder} - actualPayments, err := paymentModel.GetAll(ctx, ¶ms, dbConnectionPool) + actualPayments, err := paymentModel.GetAll(ctx, ¶ms, dbConnectionPool, QueryTypeSelectPaginated) require.NoError(t, err) assert.Equal(t, 2, len(actualPayments)) assert.Equal(t, []Payment{*expectedPayment2, *expectedPayment1}, actualPayments) @@ -315,7 +315,7 @@ func Test_PaymentModelGetAll(t *testing.T) { Page: 1, PageLimit: 1, } - actualPayments, err := paymentModel.GetAll(ctx, ¶ms, dbConnectionPool) + actualPayments, err := paymentModel.GetAll(ctx, ¶ms, dbConnectionPool, QueryTypeSelectPaginated) require.NoError(t, err) assert.Equal(t, 1, len(actualPayments)) assert.Equal(t, []Payment{*expectedPayment2}, actualPayments) @@ -328,14 +328,15 @@ func Test_PaymentModelGetAll(t *testing.T) { SortBy: DefaultPaymentSortField, SortOrder: DefaultPaymentSortOrder, } - actualPayments, err := paymentModel.GetAll(ctx, ¶ms, dbConnectionPool) + actualPayments, err := paymentModel.GetAll(ctx, ¶ms, dbConnectionPool, QueryTypeSelectPaginated) require.NoError(t, err) assert.Equal(t, 1, len(actualPayments)) assert.Equal(t, []Payment{*expectedPayment1}, actualPayments) }) t.Run("returns payments successfully with created at order", func(t *testing.T) { - actualPayments, err := paymentModel.GetAll(ctx, &QueryParams{SortBy: SortFieldCreatedAt, SortOrder: SortOrderASC}, dbConnectionPool) + params := &QueryParams{SortBy: SortFieldCreatedAt, SortOrder: SortOrderASC} + actualPayments, err := paymentModel.GetAll(ctx, params, dbConnectionPool, QueryTypeSelectPaginated) require.NoError(t, err) assert.Equal(t, 2, len(actualPayments)) @@ -343,7 +344,8 @@ func Test_PaymentModelGetAll(t *testing.T) { }) t.Run("returns payments successfully with updated at order", func(t *testing.T) { - actualPayments, err := paymentModel.GetAll(ctx, &QueryParams{SortBy: SortFieldUpdatedAt, SortOrder: SortOrderASC}, dbConnectionPool) + params := &QueryParams{SortBy: SortFieldUpdatedAt, SortOrder: SortOrderASC} + actualPayments, err := paymentModel.GetAll(ctx, params, dbConnectionPool, QueryTypeSelectPaginated) require.NoError(t, err) assert.Equal(t, 2, len(actualPayments)) @@ -354,7 +356,7 @@ func Test_PaymentModelGetAll(t *testing.T) { filters := map[FilterKey]interface{}{ FilterKeyStatus: PendingPaymentStatus, } - actualPayments, err := paymentModel.GetAll(ctx, &QueryParams{Filters: filters}, dbConnectionPool) + actualPayments, err := paymentModel.GetAll(ctx, &QueryParams{Filters: filters}, dbConnectionPool, QueryTypeSelectPaginated) require.NoError(t, err) assert.Equal(t, 1, len(actualPayments)) assert.Equal(t, []Payment{*expectedPayment2}, actualPayments) @@ -372,7 +374,7 @@ func Test_PaymentModelGetAll(t *testing.T) { SortBy: DefaultPaymentSortField, SortOrder: DefaultPaymentSortOrder, } - actualPayments, err := paymentModel.GetAll(ctx, &queryParams, dbConnectionPool) + actualPayments, err := paymentModel.GetAll(ctx, &queryParams, dbConnectionPool, QueryTypeSelectPaginated) require.NoError(t, err) assert.Equal(t, 2, len(actualPayments)) assert.Equal(t, []Payment{*expectedPayment2, *expectedPayment1}, actualPayments) @@ -435,7 +437,7 @@ func Test_PaymentModelGetAll(t *testing.T) { }, SortBy: DefaultPaymentSortField, SortOrder: DefaultPaymentSortOrder, - }, dbConnectionPool) + }, dbConnectionPool, QueryTypeSelectPaginated) require.NoError(t, err) assert.Len(t, payments, 2) @@ -606,7 +608,7 @@ func Test_PaymentNewPaymentQuery(t *testing.T) { name string baseQuery string queryParams QueryParams - paginated bool + queryType QueryType expectedQuery string expectedParams []interface{} }{ @@ -614,7 +616,7 @@ func Test_PaymentNewPaymentQuery(t *testing.T) { name: "build payment query without params and pagination", baseQuery: "SELECT * FROM payments p", queryParams: QueryParams{}, - paginated: false, + queryType: QueryTypeSelectAll, expectedQuery: "SELECT * FROM payments p", expectedParams: []interface{}{}, }, @@ -626,7 +628,7 @@ func Test_PaymentNewPaymentQuery(t *testing.T) { FilterKeyStatus: "draft", }, }, - paginated: false, + queryType: QueryTypeSelectAll, expectedQuery: "SELECT * FROM payments p WHERE 1=1 AND p.status = $1", expectedParams: []interface{}{"draft"}, }, @@ -638,7 +640,7 @@ func Test_PaymentNewPaymentQuery(t *testing.T) { FilterKeyReceiverID: "receiver_id", }, }, - paginated: false, + queryType: QueryTypeSelectAll, expectedQuery: "SELECT * FROM payments p WHERE 1=1 AND p.receiver_id = $1", expectedParams: []interface{}{"receiver_id"}, }, @@ -651,7 +653,7 @@ func Test_PaymentNewPaymentQuery(t *testing.T) { FilterKeyCreatedAtBefore: "00-01-31", }, }, - paginated: false, + queryType: QueryTypeSelectAll, expectedQuery: "SELECT * FROM payments p WHERE 1=1 AND p.created_at >= $1 AND p.created_at <= $2", expectedParams: []interface{}{"00-01-01", "00-01-31"}, }, @@ -664,7 +666,7 @@ func Test_PaymentNewPaymentQuery(t *testing.T) { SortBy: "created_at", SortOrder: "ASC", }, - paginated: true, + queryType: QueryTypeSelectPaginated, expectedQuery: "SELECT * FROM payments p ORDER BY p.created_at ASC LIMIT $1 OFFSET $2", expectedParams: []interface{}{20, 0}, }, @@ -683,7 +685,7 @@ func Test_PaymentNewPaymentQuery(t *testing.T) { FilterKeyCreatedAtBefore: "00-01-31", }, }, - paginated: true, + queryType: QueryTypeSelectPaginated, expectedQuery: "SELECT * FROM payments p WHERE 1=1 AND p.status = $1 AND p.receiver_id = $2 AND p.created_at >= $3 AND p.created_at <= $4 ORDER BY p.created_at ASC LIMIT $5 OFFSET $6", expectedParams: []interface{}{"draft", "receiver_id", "00-01-01", "00-01-31", 20, 0}, }, @@ -691,7 +693,7 @@ func Test_PaymentNewPaymentQuery(t *testing.T) { for _, tc := range testCases { t.Run(tc.name, func(t *testing.T) { - query, params := newPaymentQuery(tc.baseQuery, &tc.queryParams, tc.paginated, dbConnectionPool) + query, params := newPaymentQuery(tc.baseQuery, &tc.queryParams, dbConnectionPool, tc.queryType) assert.Equal(t, tc.expectedQuery, query) assert.Equal(t, tc.expectedParams, params) diff --git a/internal/serve/httphandler/assets_handler_test.go b/internal/serve/httphandler/assets_handler_test.go index 2e7402e39..2411db55a 100644 --- a/internal/serve/httphandler/assets_handler_test.go +++ b/internal/serve/httphandler/assets_handler_test.go @@ -1456,7 +1456,7 @@ func Test_AssetHandler_submitChangeTrustTransaction_makeSurePreconditionsAreSetA timeSinceCreation := time.Since(creationTime) expectedAdjustedMaxTime := expectedMaxTime.Add(timeSinceCreation) - require.WithinDuration(t, expectedAdjustedMaxTime, actualMaxTime, 5*time.Second) + require.WithinDuration(t, expectedAdjustedMaxTime, actualMaxTime, 10*time.Second) } } diff --git a/internal/serve/httphandler/export_handler.go b/internal/serve/httphandler/export_handler.go index 0577a134e..38090de56 100644 --- a/internal/serve/httphandler/export_handler.go +++ b/internal/serve/httphandler/export_handler.go @@ -48,3 +48,75 @@ func (e ExportHandler) ExportDisbursements(rw http.ResponseWriter, r *http.Reque return } } + +type PaymentCSV struct { + ID string + Amount string + StellarTransactionID string + Status data.PaymentStatus + DisbursementID string `csv:"Disbursement.ID"` + Asset data.Asset + Wallet data.Wallet + ReceiverID string `csv:"Receiver.ID"` + ReceiverWalletAddress string `csv:"ReceiverWallet.Address"` + ReceiverWalletStatus data.ReceiversWalletStatus `csv:"ReceiverWallet.Status"` + CreatedAt time.Time + UpdatedAt time.Time + ExternalPaymentID string + CircleTransferRequestID *string +} + +func (e ExportHandler) ExportPayments(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + validator := validators.NewPaymentQueryValidator() + queryParams := validator.ParseParametersFromRequest(r) + + if validator.HasErrors() { + httperror.BadRequest("Request invalid", nil, validator.Errors).Render(rw) + return + } + + queryParams.Filters = validator.ValidateAndGetPaymentFilters(queryParams.Filters) + if validator.HasErrors() { + httperror.BadRequest("Request invalid", nil, validator.Errors).Render(rw) + return + } + + payments, err := e.Models.Payment.GetAll(ctx, queryParams, e.Models.DBConnectionPool, data.QueryTypeSelectAll) + if err != nil { + httperror.InternalError(ctx, "Failed to get payments", err, nil).Render(rw) + return + } + + // Convert payments to PaymentCSV + paymentCSVs := make([]*PaymentCSV, 0, len(payments)) + for _, payment := range payments { + paymentCSV := &PaymentCSV{ + ID: payment.ID, + Amount: payment.Amount, + StellarTransactionID: payment.StellarTransactionID, + Status: payment.Status, + DisbursementID: payment.Disbursement.ID, + Asset: payment.Asset, + Wallet: payment.ReceiverWallet.Wallet, + ReceiverID: payment.ReceiverWallet.Receiver.ID, + ReceiverWalletAddress: payment.ReceiverWallet.StellarAddress, + ReceiverWalletStatus: payment.ReceiverWallet.Status, + CreatedAt: payment.CreatedAt, + UpdatedAt: payment.UpdatedAt, + ExternalPaymentID: payment.ExternalPaymentID, + CircleTransferRequestID: payment.CircleTransferRequestID, + } + paymentCSVs = append(paymentCSVs, paymentCSV) + } + + fileName := fmt.Sprintf("payments_%s.csv", time.Now().Format("2006-01-02-15-04-05")) + rw.Header().Set("Content-Type", "text/csv") + rw.Header().Set("Content-Disposition", fmt.Sprintf("attachment; filename=%s", fileName)) + + if err := gocsv.Marshal(paymentCSVs, rw); err != nil { + httperror.InternalError(ctx, "Failed to write CSV", err, nil).Render(rw) + return + } +} diff --git a/internal/serve/httphandler/export_handler_test.go b/internal/serve/httphandler/export_handler_test.go index aaad87fda..f2274a966 100644 --- a/internal/serve/httphandler/export_handler_test.go +++ b/internal/serve/httphandler/export_handler_test.go @@ -132,3 +132,146 @@ func Test_ExportHandler_ExportDisbursements(t *testing.T) { }) } } + +func Test_ExportHandler_ExportPayments(t *testing.T) { + dbt := dbtest.Open(t) + defer dbt.Close() + + ctx := context.Background() + + dbConnectionPool, err := db.OpenDBConnectionPool(dbt.DSN) + require.NoError(t, err) + defer dbConnectionPool.Close() + + models, err := data.NewModels(dbConnectionPool) + require.NoError(t, err) + + handler := &ExportHandler{ + Models: models, + } + + r := chi.NewRouter() + r.Get("/exports/payments", handler.ExportPayments) + + wallet := data.CreateWalletFixture(t, ctx, dbConnectionPool, "Wallet", "https://www.wallet.com", "www.wallet.com", "wallet://") + asset := data.CreateAssetFixture(t, ctx, dbConnectionPool, "USDC", "GA5ZSEJYB37JRC5AVCIA5MOP4RHTM335X2KGX3IHOJAPP5RE34K4KZVV") + receiverReady := data.CreateReceiverFixture(t, ctx, dbConnectionPool, &data.Receiver{}) + rwReady := data.CreateReceiverWalletFixture(t, ctx, dbConnectionPool, receiverReady.ID, wallet.ID, data.RegisteredReceiversWalletStatus) + + disbursement := data.CreateDisbursementFixture(t, ctx, dbConnectionPool, handler.Models.Disbursements, &data.Disbursement{ + Name: "disbursement 1", + Status: data.StartedDisbursementStatus, + Wallet: wallet, + Asset: asset, + }) + + pendingPayment := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursement, + Asset: *asset, + Amount: "100", + Status: data.PendingPaymentStatus, + CreatedAt: time.Date(2024, 3, 21, 23, 40, 20, 1431, time.UTC), + ExternalPaymentID: "PAY01", + }) + successfulPayment := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursement, + Asset: *asset, + Amount: "200", + Status: data.SuccessPaymentStatus, + CreatedAt: time.Date(2023, 3, 21, 23, 40, 20, 1431, time.UTC), + ExternalPaymentID: "PAY02", + }) + failedPayment := data.CreatePaymentFixture(t, ctx, dbConnectionPool, models.Payment, &data.Payment{ + ReceiverWallet: rwReady, + Disbursement: disbursement, + Asset: *asset, + Amount: "300", + Status: data.FailedPaymentStatus, + CreatedAt: time.Date(2022, 3, 21, 23, 40, 20, 1431, time.UTC), + ExternalPaymentID: "PAY03", + }) + + tests := []struct { + name string + queryParams string + expectedStatusCode int + expectedPayments []*data.Payment + }{ + { + name: "success - returns CSV with no payments", + queryParams: "status=draft", + expectedStatusCode: http.StatusOK, + expectedPayments: []*data.Payment{}, + }, + { + name: "success - returns CSV with all payments", + expectedStatusCode: http.StatusOK, + queryParams: "sort=created_at", + expectedPayments: []*data.Payment{pendingPayment, successfulPayment, failedPayment}, + }, + { + name: "success - return CSV with reverse order of payments", + expectedStatusCode: http.StatusOK, + queryParams: "sort=created_at&direction=asc", + expectedPayments: []*data.Payment{failedPayment, successfulPayment, pendingPayment}, + }, + { + name: "success - return CSV with only successful payments", + expectedStatusCode: http.StatusOK, + queryParams: "status=success", + expectedPayments: []*data.Payment{successfulPayment}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + url := "/exports/payments" + if tc.queryParams != "" { + url += "?" + tc.queryParams + } + req, err := http.NewRequest(http.MethodGet, url, nil) + require.NoError(t, err) + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + assert.Equal(t, tc.expectedStatusCode, rr.Code) + csvReader := csv.NewReader(strings.NewReader(rr.Body.String())) + + header, err := csvReader.Read() + require.NoError(t, err) + + expectedHeaders := []string{ + "ID", "Amount", "StellarTransactionID", "Status", + "Disbursement.ID", "Asset.Code", "Asset.Issuer", "Wallet.Name", "Receiver.ID", + "ReceiverWallet.Address", "ReceiverWallet.Status", "CreatedAt", "UpdatedAt", + "ExternalPaymentID", "CircleTransferRequestID", + } + assert.Equal(t, expectedHeaders, header) + + assert.Equal(t, "text/csv", rr.Header().Get("Content-Type")) + today := time.Now().Format("2006-01-02") + assert.Contains(t, rr.Header().Get("Content-Disposition"), fmt.Sprintf("attachment; filename=payments_%s", today)) + + rows, err := csvReader.ReadAll() + require.NoError(t, err) + assert.Len(t, rows, len(tc.expectedPayments)) + + for i, row := range rows { + assert.Equal(t, tc.expectedPayments[i].ID, row[0]) + assert.Equal(t, tc.expectedPayments[i].Amount, row[1]) + assert.Equal(t, tc.expectedPayments[i].StellarTransactionID, row[2]) + assert.Equal(t, string(tc.expectedPayments[i].Status), row[3]) + assert.Equal(t, tc.expectedPayments[i].Disbursement.ID, row[4]) + assert.Equal(t, tc.expectedPayments[i].Asset.Code, row[5]) + assert.Equal(t, tc.expectedPayments[i].Asset.Issuer, row[6]) + assert.Equal(t, tc.expectedPayments[i].ReceiverWallet.Wallet.Name, row[7]) + assert.Equal(t, tc.expectedPayments[i].ReceiverWallet.Receiver.ID, row[8]) + assert.Equal(t, tc.expectedPayments[i].ReceiverWallet.StellarAddress, row[9]) + assert.Equal(t, string(tc.expectedPayments[i].ReceiverWallet.Status), row[10]) + assert.Equal(t, tc.expectedPayments[i].ExternalPaymentID, row[13]) + } + }) + } +} diff --git a/internal/serve/httphandler/payments_handler.go b/internal/serve/httphandler/payments_handler.go index d647674e3..d5ae7d1f2 100644 --- a/internal/serve/httphandler/payments_handler.go +++ b/internal/serve/httphandler/payments_handler.go @@ -258,7 +258,7 @@ func (p PaymentsHandler) getPaymentsWithCount(ctx context.Context, queryParams * var payments []data.Payment if totalPayments != 0 { - payments, innerErr = p.Models.Payment.GetAll(ctx, queryParams, dbTx) + payments, innerErr = p.Models.Payment.GetAll(ctx, queryParams, dbTx, data.QueryTypeSelectPaginated) if innerErr != nil { return nil, fmt.Errorf("error querying payments: %w", innerErr) } diff --git a/internal/serve/serve.go b/internal/serve/serve.go index 97b4ddd6d..7f3bbcd65 100644 --- a/internal/serve/serve.go +++ b/internal/serve/serve.go @@ -419,6 +419,7 @@ func handleHTTP(o ServeOptions) *chi.Mux { r.With(middleware.AnyRoleMiddleware(authManager, data.OwnerUserRole, data.FinancialControllerUserRole)). Route("/exports", func(r chi.Router) { r.Get("/disbursements", exportHandler.ExportDisbursements) + r.Get("/payments", exportHandler.ExportPayments) }) }) diff --git a/internal/serve/serve_test.go b/internal/serve/serve_test.go index ff112f280..cb506801b 100644 --- a/internal/serve/serve_test.go +++ b/internal/serve/serve_test.go @@ -480,6 +480,7 @@ func Test_handleHTTP_authenticatedEndpoints(t *testing.T) { {http.MethodGet, "/balances"}, // Exports {http.MethodGet, "/exports/disbursements"}, + {http.MethodGet, "/exports/payments"}, } // Expect 401 as a response: diff --git a/internal/services/disbursement_management_service.go b/internal/services/disbursement_management_service.go index 0785287fb..c4e229989 100644 --- a/internal/services/disbursement_management_service.go +++ b/internal/services/disbursement_management_service.go @@ -346,7 +346,7 @@ func (s *DisbursementManagementService) validateBalanceForDisbursement( Filters: map[data.FilterKey]interface{}{ data.FilterKeyStatus: data.PaymentInProgressStatuses(), }, - }, dbTx) + }, dbTx, data.QueryTypeSelectAll) if err != nil { return fmt.Errorf("cannot retrieve incomplete payments: %w", err) }