diff --git a/api/gateway/chain/go.mod b/api/gateway/chain/go.mod index 498cc8b..c538c04 100644 --- a/api/gateway/chain/go.mod +++ b/api/gateway/chain/go.mod @@ -22,7 +22,7 @@ require ( require ( github.com/Microsoft/go-winio v0.6.2 // indirect - github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549 // indirect + github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b // indirect github.com/beorn7/perks v1.0.1 // indirect github.com/bits-and-blooms/bitset v1.24.4 // indirect github.com/bmatcuk/doublestar/v4 v4.9.1 // indirect diff --git a/api/gateway/chain/go.sum b/api/gateway/chain/go.sum index 99a62fb..1408b9b 100644 --- a/api/gateway/chain/go.sum +++ b/api/gateway/chain/go.sum @@ -6,8 +6,8 @@ github.com/DataDog/zstd v1.4.5 h1:EndNeuB0l9syBZhut0wns3gV1hL8zX8LIu6ZiVHWLIQ= github.com/DataDog/zstd v1.4.5/go.mod h1:1jcaCB/ufaK+sKp1NBhlGmpz41jOoPQ35bpF36t7BBo= github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERoyfY= github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549 h1:NERDcANvDCnspxdMEMLXOMnuITWIWrTQvvhEA8ewBBM= -github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251225023818-8886bb81c549/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b h1:g/wCbvJGhOAqfGBjWnqtD6CVsXdr3G4GCbjLR6z9kNw= +github.com/ProjectZKM/Ziren/crates/go-runtime/zkvm_runtime v0.0.0-20251229120209-a0d175451f7b/go.mod h1:ioLG6R+5bUSO1oeGSDxOV3FADARuMoytZCSX6MEMQkI= github.com/VictoriaMetrics/fastcache v1.13.0 h1:AW4mheMR5Vd9FkAPUv+NH6Nhw+fmbTMGMsNAoA/+4G0= github.com/VictoriaMetrics/fastcache v1.13.0/go.mod h1:hHXhl4DA2fTL2HTZDJFXWgW0LNjo6B+4aj2Wmng3TjU= github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= diff --git a/api/server/interface/api/sresponse/payment.go b/api/server/interface/api/sresponse/payment.go index 5ebf122..e459d85 100644 --- a/api/server/interface/api/sresponse/payment.go +++ b/api/server/interface/api/sresponse/payment.go @@ -7,6 +7,7 @@ import ( "github.com/tech/sendico/pkg/mlogger" "github.com/tech/sendico/pkg/model" feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1" + paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" oraclev1 "github.com/tech/sendico/pkg/proto/oracle/v1" orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" ) @@ -75,7 +76,8 @@ type paymentQuotesResponse struct { type paymentsResponse struct { authResponse `json:",inline"` - Payments []Payment `json:"payments"` + Payments []Payment `json:"payments"` + Page *paginationv1.CursorPageResponse `json:"page,omitempty"` } type paymentResponse struct { @@ -107,6 +109,15 @@ func PaymentsResponse(logger mlogger.Logger, payments []*orchestratorv1.Payment, }) } +// PaymentsList wraps a list of payments with refreshed access token and pagination data. +func PaymentsListResponse(logger mlogger.Logger, resp *orchestratorv1.ListPaymentsResponse, token *TokenData) http.HandlerFunc { + return response.Ok(logger, paymentsResponse{ + Payments: toPayments(resp.GetPayments()), + Page: resp.GetPage(), + authResponse: authResponse{AccessToken: *token}, + }) +} + // Payment wraps a payment with refreshed access token. func PaymentResponse(logger mlogger.Logger, payment *orchestratorv1.Payment, token *TokenData) http.HandlerFunc { return response.Ok(logger, paymentResponse{ diff --git a/api/server/internal/server/paymentapiimp/list.go b/api/server/internal/server/paymentapiimp/list.go new file mode 100644 index 0000000..a6cb598 --- /dev/null +++ b/api/server/internal/server/paymentapiimp/list.go @@ -0,0 +1,153 @@ +package paymentapiimp + +import ( + "net/http" + "strconv" + "strings" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/model" + "github.com/tech/sendico/pkg/mutil/mzap" + paginationv1 "github.com/tech/sendico/pkg/proto/common/pagination/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "github.com/tech/sendico/server/interface/api/sresponse" + mutil "github.com/tech/sendico/server/internal/mutil/param" + "go.mongodb.org/mongo-driver/bson/primitive" + "go.uber.org/zap" +) + +const maxInt32 = int64(1<<31 - 1) + +func (a *PaymentAPI) listPayments(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc { + orgRef, err := a.oph.GetRef(r) + if err != nil { + a.logger.Warn("Failed to parse organization reference for payments list", zap.Error(err), mutil.PLog(a.oph, r)) + return response.BadReference(a.logger, a.Name(), a.oph.Name(), a.oph.GetID(r), err) + } + + ctx := r.Context() + allowed, err := a.enf.Enforce(ctx, a.permissionRef, account.ID, orgRef, primitive.NilObjectID, model.ActionRead) + if err != nil { + a.logger.Warn("Failed to check payments access permissions", zap.Error(err), mutil.PLog(a.oph, r)) + return response.Auto(a.logger, a.Name(), err) + } + if !allowed { + a.logger.Debug("Access denied when listing payments", mutil.PLog(a.oph, r)) + return response.AccessDenied(a.logger, a.Name(), "payments read permission denied") + } + + req := &orchestratorv1.ListPaymentsRequest{ + Meta: &orchestratorv1.RequestMeta{ + OrganizationRef: orgRef.Hex(), + }, + } + + if page, err := listPaymentsPage(r); err != nil { + return response.Auto(a.logger, a.Name(), err) + } else if page != nil { + req.Page = page + } + + query := r.URL.Query() + if sourceRef := strings.TrimSpace(query.Get("source_ref")); sourceRef != "" { + req.SourceRef = sourceRef + } + if destinationRef := strings.TrimSpace(query.Get("destination_ref")); destinationRef != "" { + req.DestinationRef = destinationRef + } + + if states, err := parsePaymentStateFilters(r); err != nil { + return response.Auto(a.logger, a.Name(), err) + } else if len(states) > 0 { + req.FilterStates = states + } + + resp, err := a.client.ListPayments(ctx, req) + if err != nil { + a.logger.Warn("Failed to list payments", zap.Error(err), mzap.ObjRef("organization_ref", orgRef)) + return response.Auto(a.logger, a.Name(), err) + } + + return sresponse.PaymentsListResponse(a.logger, resp, token) +} + +func listPaymentsPage(r *http.Request) (*paginationv1.CursorPageRequest, error) { + query := r.URL.Query() + cursor := strings.TrimSpace(query.Get("cursor")) + limitRaw := strings.TrimSpace(query.Get("limit")) + + var limit int64 + hasLimit := false + if limitRaw != "" { + parsed, err := strconv.ParseInt(limitRaw, 10, 32) + if err != nil { + return nil, merrors.InvalidArgument("invalid limit", "limit") + } + limit = parsed + hasLimit = true + } + + if cursor == "" && !hasLimit { + return nil, nil + } + + page := &paginationv1.CursorPageRequest{ + Cursor: cursor, + } + if hasLimit { + if limit < 0 { + limit = 0 + } else if limit > maxInt32 { + limit = maxInt32 + } + page.Limit = int32(limit) + } + + return page, nil +} + +func parsePaymentStateFilters(r *http.Request) ([]orchestratorv1.PaymentState, error) { + query := r.URL.Query() + values := append([]string{}, query["state"]...) + values = append(values, query["states"]...) + values = append(values, query["filter_states"]...) + if len(values) == 0 { + return nil, nil + } + + states := make([]orchestratorv1.PaymentState, 0, len(values)) + for _, raw := range values { + for _, part := range strings.Split(raw, ",") { + trimmed := strings.TrimSpace(part) + if trimmed == "" { + continue + } + state, ok := paymentStateFromString(trimmed) + if !ok { + return nil, merrors.InvalidArgument("unsupported payment state: "+trimmed, "state") + } + states = append(states, state) + } + } + + if len(states) == 0 { + return nil, nil + } + return states, nil +} + +func paymentStateFromString(value string) (orchestratorv1.PaymentState, bool) { + upper := strings.ToUpper(strings.TrimSpace(value)) + if upper == "" { + return 0, false + } + if !strings.HasPrefix(upper, "PAYMENT_STATE_") { + upper = "PAYMENT_STATE_" + upper + } + enumValue, ok := orchestratorv1.PaymentState_value[upper] + if !ok { + return 0, false + } + return orchestratorv1.PaymentState(enumValue), true +} diff --git a/api/server/internal/server/paymentapiimp/service.go b/api/server/internal/server/paymentapiimp/service.go index a50f8cc..d8b7eaa 100644 --- a/api/server/internal/server/paymentapiimp/service.go +++ b/api/server/internal/server/paymentapiimp/service.go @@ -25,6 +25,7 @@ type paymentClient interface { QuotePayments(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) + ListPayments(ctx context.Context, req *orchestratorv1.ListPaymentsRequest) (*orchestratorv1.ListPaymentsResponse, error) Close() error } @@ -72,6 +73,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) { apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/immediate"), api.Post, p.initiateImmediate) apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-quote"), api.Post, p.initiateByQuote) apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/by-multiquote"), api.Post, p.initiatePaymentsByQuote) + apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/"), api.Get, p.listPayments) return p, nil } diff --git a/frontend/pshared/lib/api/responses/payment/payments.dart b/frontend/pshared/lib/api/responses/payment/payments.dart new file mode 100644 index 0000000..4209827 --- /dev/null +++ b/frontend/pshared/lib/api/responses/payment/payments.dart @@ -0,0 +1,20 @@ +import 'package:json_annotation/json_annotation.dart'; + +import 'package:pshared/api/responses/base.dart'; +import 'package:pshared/api/responses/token.dart'; +import 'package:pshared/data/dto/payment/payment.dart'; + +part 'payments.g.dart'; + + +@JsonSerializable(explicitToJson: true) +class PaymentsResponse extends BaseAuthorizedResponse { + + final List payments; + + const PaymentsResponse({required super.accessToken, required this.payments}); + + factory PaymentsResponse.fromJson(Map json) => _$PaymentsResponseFromJson(json); + @override + Map toJson() => _$PaymentsResponseToJson(this); +} diff --git a/frontend/pshared/lib/service/payment/service.dart b/frontend/pshared/lib/service/payment/service.dart index a72b6f8..f47787c 100644 --- a/frontend/pshared/lib/service/payment/service.dart +++ b/frontend/pshared/lib/service/payment/service.dart @@ -1,8 +1,10 @@ import 'package:logging/logging.dart'; + import 'package:uuid/uuid.dart'; import 'package:pshared/api/requests/payment/initiate.dart'; import 'package:pshared/api/responses/payment/payment.dart'; +import 'package:pshared/api/responses/payment/payments.dart'; import 'package:pshared/data/mapper/payment/payment_response.dart'; import 'package:pshared/models/payment/payment.dart'; import 'package:pshared/service/authorization/service.dart'; @@ -13,6 +15,40 @@ class PaymentService { static final _logger = Logger('service.payment'); static const String _objectType = Services.payments; + static Future> list( + String organizationRef, { + int? limit, + String? cursor, + String? sourceRef, + String? destinationRef, + List? states, + }) async { + _logger.fine('Listing payments for organization $organizationRef'); + final queryParams = {}; + if (limit != null) { + queryParams['limit'] = limit.toString(); + } + if (cursor != null && cursor.isNotEmpty) { + queryParams['cursor'] = cursor; + } + if (sourceRef != null && sourceRef.isNotEmpty) { + queryParams['source_ref'] = sourceRef; + } + if (destinationRef != null && destinationRef.isNotEmpty) { + queryParams['destination_ref'] = destinationRef; + } + if (states != null && states.isNotEmpty) { + queryParams['state'] = states.join(','); + } + + final path = '/$organizationRef'; + final url = queryParams.isEmpty + ? path + : Uri(path: path, queryParameters: queryParams).toString(); + final response = await AuthorizationService.getGETResponse(_objectType, url); + return PaymentsResponse.fromJson(response).payments.map((payment) => payment.toDomain()).toList(); + } + static Future pay( String organizationRef, String quotationRef, {