diff --git a/api/payments/orchestrator/client/client.go b/api/payments/orchestrator/client/client.go index e0fb974..355d3e0 100644 --- a/api/payments/orchestrator/client/client.go +++ b/api/payments/orchestrator/client/client.go @@ -18,6 +18,7 @@ import ( type Client interface { QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) 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) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) GetPayment(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) @@ -31,6 +32,7 @@ type Client interface { type grpcOrchestratorClient interface { QuotePayment(ctx context.Context, in *orchestratorv1.QuotePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentResponse, error) QuotePayments(ctx context.Context, in *orchestratorv1.QuotePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.QuotePaymentsResponse, error) + InitiatePayments(ctx context.Context, in *orchestratorv1.InitiatePaymentsRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentsResponse, error) InitiatePayment(ctx context.Context, in *orchestratorv1.InitiatePaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.InitiatePaymentResponse, error) CancelPayment(ctx context.Context, in *orchestratorv1.CancelPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.CancelPaymentResponse, error) GetPayment(ctx context.Context, in *orchestratorv1.GetPaymentRequest, opts ...grpc.CallOption) (*orchestratorv1.GetPaymentResponse, error) @@ -105,6 +107,12 @@ func (c *orchestratorClient) QuotePayments(ctx context.Context, req *orchestrato return c.client.QuotePayments(ctx, req) } +func (c *orchestratorClient) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) { + ctx, cancel := c.callContext(ctx) + defer cancel() + return c.client.InitiatePayments(ctx, req) +} + func (c *orchestratorClient) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) { ctx, cancel := c.callContext(ctx) defer cancel() diff --git a/api/payments/orchestrator/client/fake.go b/api/payments/orchestrator/client/fake.go index d252e12..b30a343 100644 --- a/api/payments/orchestrator/client/fake.go +++ b/api/payments/orchestrator/client/fake.go @@ -10,6 +10,7 @@ import ( type Fake struct { QuotePaymentFn func(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) QuotePaymentsFn func(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) (*orchestratorv1.QuotePaymentsResponse, error) + InitiatePaymentsFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) InitiatePaymentFn func(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) CancelPaymentFn func(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) GetPaymentFn func(ctx context.Context, req *orchestratorv1.GetPaymentRequest) (*orchestratorv1.GetPaymentResponse, error) @@ -34,6 +35,13 @@ func (f *Fake) QuotePayments(ctx context.Context, req *orchestratorv1.QuotePayme return &orchestratorv1.QuotePaymentsResponse{}, nil } +func (f *Fake) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) { + if f.InitiatePaymentsFn != nil { + return f.InitiatePaymentsFn(ctx, req) + } + return &orchestratorv1.InitiatePaymentsResponse{}, nil +} + func (f *Fake) InitiatePayment(ctx context.Context, req *orchestratorv1.InitiatePaymentRequest) (*orchestratorv1.InitiatePaymentResponse, error) { if f.InitiatePaymentFn != nil { return f.InitiatePaymentFn(ctx, req) diff --git a/api/payments/orchestrator/internal/service/orchestrator/command_factory.go b/api/payments/orchestrator/internal/service/orchestrator/command_factory.go index e1eb5fa..359a32d 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/command_factory.go +++ b/api/payments/orchestrator/internal/service/orchestrator/command_factory.go @@ -75,6 +75,13 @@ func (f *paymentCommandFactory) InitiatePayment() *initiatePaymentCommand { } } +func (f *paymentCommandFactory) InitiatePayments() *initiatePaymentsCommand { + return &initiatePaymentsCommand{ + engine: f.engine, + logger: f.logger.Named("initiate_payments"), + } +} + func (f *paymentCommandFactory) CancelPayment() *cancelPaymentCommand { return &cancelPaymentCommand{ engine: f.engine, diff --git a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go index 16bf01b..4f40e5a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go +++ b/api/payments/orchestrator/internal/service/orchestrator/handlers_commands.go @@ -146,6 +146,98 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1. }) } +type initiatePaymentsCommand struct { + engine paymentEngine + logger mlogger.Logger +} + +func (h *initiatePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) gsresponse.Responder[orchestratorv1.InitiatePaymentsResponse] { + if err := h.engine.EnsureRepository(ctx); err != nil { + return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + if req == nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request")) + } + _, orgID, err := validateMetaAndOrgRef(req.GetMeta()) + if err != nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + idempotencyKey, err := requireIdempotencyKey(req.GetIdempotencyKey()) + if err != nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + quoteRef := strings.TrimSpace(req.GetQuoteRef()) + if quoteRef == "" { + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref is required")) + } + + quotesStore, err := ensureQuotesStore(h.engine.Repository()) + if err != nil { + return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + record, err := quotesStore.GetByRef(ctx, orgID, quoteRef) + if err != nil { + if errors.Is(err, storage.ErrQuoteNotFound) { + return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired")) + } + return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + intents := record.Intents + quotes := record.Quotes + if len(intents) == 0 && record.Intent.Kind != "" && record.Intent.Kind != model.PaymentKindUnspecified { + intents = []model.PaymentIntent{record.Intent} + } + if len(quotes) == 0 && record.Quote != nil { + quotes = []*model.PaymentQuoteSnapshot{record.Quote} + } + if len(intents) == 0 || len(quotes) == 0 || len(intents) != len(quotes) { + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote payload is incomplete")) + } + + store, err := ensurePaymentsStore(h.engine.Repository()) + if err != nil { + return gsresponse.Unavailable[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + payments := make([]*orchestratorv1.Payment, 0, len(intents)) + for i := range intents { + intentProto := protoIntentFromModel(intents[i]) + if err := requireNonNilIntent(intentProto); err != nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + quoteProto := modelQuoteToProto(quotes[i]) + if quoteProto == nil { + return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty")) + } + quoteProto.QuoteRef = quoteRef + + perKey := perIntentIdempotencyKey(idempotencyKey, i, len(intents)) + if existing, err := getPaymentByIdempotencyKey(ctx, store, orgID, perKey); err == nil && existing != nil { + payments = append(payments, toProtoPayment(existing)) + continue + } else if err != nil && !errors.Is(err, storage.ErrPaymentNotFound) { + return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + entity := newPayment(orgID, intentProto, perKey, req.GetMetadata(), quoteProto) + if err = store.Create(ctx, entity); err != nil { + if errors.Is(err, storage.ErrDuplicatePayment) { + return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.DataConflict("payment already exists")) + } + return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + if err := h.engine.ExecutePayment(ctx, store, entity, quoteProto); err != nil { + return gsresponse.Auto[orchestratorv1.InitiatePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err) + } + + payments = append(payments, toProtoPayment(entity)) + } + + return gsresponse.Success(&orchestratorv1.InitiatePaymentsResponse{Payments: payments}) +} + type initiatePaymentCommand struct { engine paymentEngine logger mlogger.Logger diff --git a/api/payments/orchestrator/internal/service/orchestrator/quote_batch_test.go b/api/payments/orchestrator/internal/service/orchestrator/quote_batch_test.go new file mode 100644 index 0000000..0eae3c9 --- /dev/null +++ b/api/payments/orchestrator/internal/service/orchestrator/quote_batch_test.go @@ -0,0 +1,102 @@ +package orchestrator + +import ( + "testing" + "time" + + "github.com/shopspring/decimal" + moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1" + chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" +) + +func TestAggregatePaymentQuotes(t *testing.T) { + quotes := []*orchestratorv1.PaymentQuote{ + { + DebitAmount: &moneyv1.Money{Amount: "10", Currency: "USD"}, + ExpectedSettlementAmount: &moneyv1.Money{Amount: "8", Currency: "EUR"}, + ExpectedFeeTotal: &moneyv1.Money{Amount: "1", Currency: "USD"}, + NetworkFee: &chainv1.EstimateTransferFeeResponse{ + NetworkFee: &moneyv1.Money{Amount: "0.5", Currency: "USD"}, + }, + }, + { + DebitAmount: &moneyv1.Money{Amount: "5", Currency: "USD"}, + ExpectedSettlementAmount: &moneyv1.Money{Amount: "1000", Currency: "NGN"}, + ExpectedFeeTotal: &moneyv1.Money{Amount: "2", Currency: "USD"}, + NetworkFee: &chainv1.EstimateTransferFeeResponse{ + NetworkFee: &moneyv1.Money{Amount: "100", Currency: "NGN"}, + }, + }, + } + + agg, err := aggregatePaymentQuotes(quotes) + if err != nil { + t.Fatalf("aggregatePaymentQuotes returned error: %v", err) + } + + assertMoneyTotals(t, agg.GetDebitAmounts(), map[string]string{"USD": "15"}) + assertMoneyTotals(t, agg.GetExpectedSettlementAmounts(), map[string]string{"EUR": "8", "NGN": "1000"}) + assertMoneyTotals(t, agg.GetExpectedFeeTotals(), map[string]string{"USD": "3"}) + assertMoneyTotals(t, agg.GetNetworkFeeTotals(), map[string]string{"USD": "0.5", "NGN": "100"}) +} + +func TestAggregatePaymentQuotesInvalidAmount(t *testing.T) { + quotes := []*orchestratorv1.PaymentQuote{ + { + DebitAmount: &moneyv1.Money{Amount: "bad", Currency: "USD"}, + }, + } + if _, err := aggregatePaymentQuotes(quotes); err == nil { + t.Fatal("expected error for invalid amount") + } +} + +func TestMinQuoteExpiry(t *testing.T) { + now := time.Now().UTC() + later := now.Add(10 * time.Minute) + earliest := now.Add(5 * time.Minute) + + min, ok := minQuoteExpiry([]time.Time{later, time.Time{}, earliest}) + if !ok { + t.Fatal("expected min expiry to be set") + } + if !min.Equal(earliest) { + t.Fatalf("expected min expiry %v, got %v", earliest, min) + } + + if _, ok := minQuoteExpiry([]time.Time{time.Time{}}); ok { + t.Fatal("expected min expiry to be unset") + } +} + +func assertMoneyTotals(t *testing.T, list []*moneyv1.Money, expected map[string]string) { + t.Helper() + got := make(map[string]decimal.Decimal, len(list)) + for _, item := range list { + if item == nil { + continue + } + val, err := decimal.NewFromString(item.GetAmount()) + if err != nil { + t.Fatalf("invalid money amount %q: %v", item.GetAmount(), err) + } + got[item.GetCurrency()] = val + } + if len(got) != len(expected) { + t.Fatalf("expected %d totals, got %d", len(expected), len(got)) + } + for currency, amount := range expected { + val, err := decimal.NewFromString(amount) + if err != nil { + t.Fatalf("invalid expected amount %q: %v", amount, err) + } + gotVal, ok := got[currency] + if !ok { + t.Fatalf("missing currency %s", currency) + } + if !gotVal.Equal(val) { + t.Fatalf("expected %s %s, got %s", amount, currency, gotVal.String()) + } + } +} diff --git a/api/payments/orchestrator/internal/service/orchestrator/service.go b/api/payments/orchestrator/internal/service/orchestrator/service.go index 468a670..249375d 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service.go @@ -129,6 +129,12 @@ func (s *Service) InitiatePayment(ctx context.Context, req *orchestratorv1.Initi return executeUnary(ctx, s, "InitiatePayment", s.h.commands.InitiatePayment().Execute, req) } +// InitiatePayments executes multiple payments using a stored quote reference. +func (s *Service) InitiatePayments(ctx context.Context, req *orchestratorv1.InitiatePaymentsRequest) (*orchestratorv1.InitiatePaymentsResponse, error) { + s.ensureHandlers() + return executeUnary(ctx, s, "InitiatePayments", s.h.commands.InitiatePayments().Execute, req) +} + // CancelPayment attempts to cancel an in-flight payment. func (s *Service) CancelPayment(ctx context.Context, req *orchestratorv1.CancelPaymentRequest) (*orchestratorv1.CancelPaymentResponse, error) { s.ensureHandlers() diff --git a/api/payments/orchestrator/storage/model/quote.go b/api/payments/orchestrator/storage/model/quote.go index 4660201..7402208 100644 --- a/api/payments/orchestrator/storage/model/quote.go +++ b/api/payments/orchestrator/storage/model/quote.go @@ -12,12 +12,12 @@ type PaymentQuoteRecord struct { storable.Base `bson:",inline" json:",inline"` model.OrganizationBoundBase `bson:",inline" json:",inline"` - QuoteRef string `bson:"quoteRef" json:"quoteRef"` - Intent PaymentIntent `bson:"intent,omitempty" json:"intent,omitempty"` - Intents []PaymentIntent `bson:"intents,omitempty" json:"intents,omitempty"` - Quote *PaymentQuoteSnapshot `bson:"quote,omitempty" json:"quote,omitempty"` + QuoteRef string `bson:"quoteRef" json:"quoteRef"` + Intent PaymentIntent `bson:"intent,omitempty" json:"intent,omitempty"` + Intents []PaymentIntent `bson:"intents,omitempty" json:"intents,omitempty"` + Quote *PaymentQuoteSnapshot `bson:"quote,omitempty" json:"quote,omitempty"` Quotes []*PaymentQuoteSnapshot `bson:"quotes,omitempty" json:"quotes,omitempty"` - ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` + ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"` } // Collection implements storable.Storable. diff --git a/api/proto/payments/orchestrator/v1/orchestrator.proto b/api/proto/payments/orchestrator/v1/orchestrator.proto index 5be87b6..5f211c0 100644 --- a/api/proto/payments/orchestrator/v1/orchestrator.proto +++ b/api/proto/payments/orchestrator/v1/orchestrator.proto @@ -192,6 +192,17 @@ message QuotePaymentsResponse { repeated PaymentQuote quotes = 3; } +message InitiatePaymentsRequest { + RequestMeta meta = 1; + string idempotency_key = 2; + string quote_ref = 3; + map metadata = 4; +} + +message InitiatePaymentsResponse { + repeated Payment payments = 1; +} + message InitiatePaymentRequest { RequestMeta meta = 1; string idempotency_key = 2; @@ -280,6 +291,7 @@ message InitiateConversionResponse { service PaymentOrchestrator { rpc QuotePayment(QuotePaymentRequest) returns (QuotePaymentResponse); rpc QuotePayments(QuotePaymentsRequest) returns (QuotePaymentsResponse); + rpc InitiatePayments(InitiatePaymentsRequest) returns (InitiatePaymentsResponse); rpc InitiatePayment(InitiatePaymentRequest) returns (InitiatePaymentResponse); rpc CancelPayment(CancelPaymentRequest) returns (CancelPaymentResponse); rpc GetPayment(GetPaymentRequest) returns (GetPaymentResponse); diff --git a/api/server/interface/api/srequest/payment.go b/api/server/interface/api/srequest/payment.go index e7db600..9bf988f 100644 --- a/api/server/interface/api/srequest/payment.go +++ b/api/server/interface/api/srequest/payment.go @@ -89,3 +89,18 @@ func (r InitiatePayment) Validate() error { return nil } + +type InitiatePayments struct { + PaymentBase `json:",inline"` + QuoteRef string `json:"quoteRef,omitempty"` +} + +func (r InitiatePayments) Validate() error { + if err := r.PaymentBase.Validate(); err != nil { + return err + } + if r.QuoteRef == "" { + return merrors.InvalidArgument("quoteRef is required", "quoteRef") + } + return nil +} diff --git a/api/server/interface/api/sresponse/payment.go b/api/server/interface/api/sresponse/payment.go index b537142..6937675 100644 --- a/api/server/interface/api/sresponse/payment.go +++ b/api/server/interface/api/sresponse/payment.go @@ -57,9 +57,9 @@ type PaymentQuoteAggregate struct { } type PaymentQuotes struct { - QuoteRef string `json:"quoteRef,omitempty"` + QuoteRef string `json:"quoteRef,omitempty"` Aggregate *PaymentQuoteAggregate `json:"aggregate,omitempty"` - Quotes []PaymentQuote `json:"quotes,omitempty"` + Quotes []PaymentQuote `json:"quotes,omitempty"` } type Payment struct { @@ -81,6 +81,11 @@ type paymentQuotesResponse struct { Quote *PaymentQuotes `json:"quote"` } +type paymentsResponse struct { + authResponse `json:",inline"` + Payments []Payment `json:"payments"` +} + type paymentResponse struct { authResponse `json:",inline"` Payment *Payment `json:"payment"` @@ -102,6 +107,14 @@ func PaymentQuotesResponse(logger mlogger.Logger, resp *orchestratorv1.QuotePaym }) } +// Payments wraps a list of payments with refreshed access token. +func PaymentsResponse(logger mlogger.Logger, payments []*orchestratorv1.Payment, token *TokenData) http.HandlerFunc { + return response.Ok(logger, paymentsResponse{ + Payments: toPayments(payments), + 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{ @@ -216,6 +229,22 @@ func toPaymentQuotes(resp *orchestratorv1.QuotePaymentsResponse) *PaymentQuotes } } +func toPayments(items []*orchestratorv1.Payment) []Payment { + if len(items) == 0 { + return nil + } + result := make([]Payment, 0, len(items)) + for _, item := range items { + if p := toPayment(item); p != nil { + result = append(result, *p) + } + } + if len(result) == 0 { + return nil + } + return result +} + func toPayment(p *orchestratorv1.Payment) *Payment { if p == nil { return nil diff --git a/api/server/internal/server/paymentapiimp/paybatch.go b/api/server/internal/server/paymentapiimp/paybatch.go new file mode 100644 index 0000000..7afb16e --- /dev/null +++ b/api/server/internal/server/paymentapiimp/paybatch.go @@ -0,0 +1,74 @@ +package paymentapiimp + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/tech/sendico/pkg/api/http/response" + "github.com/tech/sendico/pkg/merrors" + "github.com/tech/sendico/pkg/model" + orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1" + "github.com/tech/sendico/server/interface/api/srequest" + "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" +) + +func (a *PaymentAPI) initiatePaymentsByQuote(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 batch payment initiation", zap.Error(err), zap.String(a.oph.Name(), a.oph.GetID(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.ActionCreate) + 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 initiating batch payments", mutil.PLog(a.oph, r)) + return response.AccessDenied(a.logger, a.Name(), "payments write permission denied") + } + + payload, err := decodeInitiatePaymentsPayload(r) + if err != nil { + return response.BadPayload(a.logger, a.Name(), err) + } + + req := &orchestratorv1.InitiatePaymentsRequest{ + Meta: &orchestratorv1.RequestMeta{ + OrganizationRef: orgRef.Hex(), + }, + IdempotencyKey: strings.TrimSpace(payload.IdempotencyKey), + QuoteRef: strings.TrimSpace(payload.QuoteRef), + Metadata: payload.Metadata, + } + + resp, err := a.client.InitiatePayments(ctx, req) + if err != nil { + a.logger.Warn("Failed to initiate batch payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) + return response.Auto(a.logger, a.Name(), err) + } + + return sresponse.PaymentsResponse(a.logger, resp.GetPayments(), token) +} + +func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments, error) { + defer r.Body.Close() + + payload := &srequest.InitiatePayments{} + if err := json.NewDecoder(r.Body).Decode(payload); err != nil { + return nil, merrors.InvalidArgument("invalid payload: " + err.Error()) + } + payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey) + payload.QuoteRef = strings.TrimSpace(payload.QuoteRef) + + if err := payload.Validate(); err != nil { + return nil, err + } + return payload, nil +} diff --git a/api/server/internal/server/paymentapiimp/service.go b/api/server/internal/server/paymentapiimp/service.go index 047796c..a50f8cc 100644 --- a/api/server/internal/server/paymentapiimp/service.go +++ b/api/server/internal/server/paymentapiimp/service.go @@ -23,6 +23,7 @@ import ( type paymentClient interface { QuotePayment(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.QuotePaymentResponse, error) 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) Close() error } @@ -67,9 +68,10 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) { } apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/quote"), api.Post, p.quotePayment) - apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/quote-multiple"), api.Post, p.quotePayments) + apiCtx.Register().AccountHandler(p.Name(), p.oph.AddRef("/multiquote"), api.Post, p.quotePayments) 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) return p, nil }