server endpoint
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user