server endpoint

This commit is contained in:
Stephan D
2025-12-17 18:15:02 +01:00
parent c7b9b70d57
commit 61177a4e30
12 changed files with 363 additions and 8 deletions

View File

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

View File

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

View File

@@ -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())
}
}
}

View File

@@ -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()