Orchestration / payments v2 #554

Merged
tech merged 23 commits from pqpov2-547 into main 2026-02-26 22:45:55 +00:00
18 changed files with 816 additions and 24 deletions
Showing only changes of commit 7661038868 - Show all commits

View File

@@ -205,7 +205,7 @@ func (s *svc) resolveAndPlan(ctx context.Context, requestCtx *reqval.Ctx) (*qsna
IntentRef: requestCtx.IntentRef,
})
if err != nil {
return nil, nil, err
return nil, nil, remapResolveError(err)
}
graph, err := s.planner.Compile(xplan.Input{
IntentSnapshot: resolved.IntentSnapshot,
@@ -269,6 +269,15 @@ func remapIdempotencyError(err error) error {
return err
}
func remapResolveError(err error) error {
switch {
case errors.Is(err, qsnap.ErrIntentRefRequired), errors.Is(err, qsnap.ErrIntentRefNotFound):
return merrors.InvalidArgument(err.Error())
default:
return err
}
}
func mustFingerprint(idemSvc idem.Service, requestCtx *reqval.Ctx) string {
if idemSvc == nil || requestCtx == nil {
return ""

View File

@@ -106,6 +106,27 @@ func TestExecutePayment_IdempotencyMismatch(t *testing.T) {
}
}
func TestExecutePayment_BatchQuoteRequiresIntentRef(t *testing.T) {
env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
step := req.StepExecution
step.State = agg.StepStateCompleted
return &sexec.ExecuteOutput{StepExecution: step}, nil
})
env.quotes.Put(newExecutableBatchQuote(env.orgID, "quote-batch", []string{"intent-a", "intent-b"}, buildLedgerRoute()))
_, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{
Meta: testMeta(env.orgID, "idem-batch"),
QuotationRef: "quote-batch",
ClientPaymentRef: "client-batch",
})
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument for missing intent_ref, got %v", err)
}
if got := err.Error(); !strings.Contains(got, "intent_ref is required for batch quotation") {
t.Fatalf("unexpected error message: %q", got)
}
}
func TestExecutePayment_RetryThenSuccess(t *testing.T) {
env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
if req.StepExecution.Attempt == 1 {
@@ -627,6 +648,39 @@ func newExecutableQuote(orgRef bson.ObjectID, quoteRef, intentRef string, route
}
}
func newExecutableBatchQuote(orgRef bson.ObjectID, quoteRef string, intentRefs []string, route *paymenttypes.QuoteRouteSpecification) *model.PaymentQuoteRecord {
now := time.Now().UTC()
items := make([]*model.PaymentQuoteItemV2, 0, len(intentRefs))
for _, intentRef := range intentRefs {
items = append(items, &model.PaymentQuoteItemV2{
Intent: &model.PaymentIntent{
Ref: intentRef,
Kind: model.PaymentKindPayout,
Source: testLedgerEndpoint("ledger-src"),
Destination: testLedgerEndpoint("ledger-dst"),
Amount: &paymenttypes.Money{Amount: "10", Currency: "USD"},
SettlementCurrency: "USD",
},
Quote: &model.PaymentQuoteSnapshot{
QuoteRef: quoteRef,
DebitAmount: &paymenttypes.Money{Amount: "10", Currency: "USD"},
Route: route,
},
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
})
}
return &model.PaymentQuoteRecord{
Base: modelBase(now),
OrganizationBoundBase: pm.OrganizationBoundBase{
OrganizationRef: orgRef,
},
QuoteRef: quoteRef,
RequestShape: model.QuoteRequestShapeBatch,
Items: items,
ExpiresAt: now.Add(1 * time.Hour),
}
}
func buildLedgerRoute() *paymenttypes.QuoteRouteSpecification {
return &paymenttypes.QuoteRouteSpecification{
Hops: []*paymenttypes.QuoteRouteHop{

View File

@@ -108,14 +108,47 @@ func (r InitiatePayment) Validate() error {
type InitiatePayments struct {
PaymentBase `json:",inline"`
QuoteRef string `json:"quoteRef,omitempty"`
IntentRef string `json:"intentRef,omitempty"`
IntentRefs []string `json:"intentRefs,omitempty"`
}
func (r InitiatePayments) Validate() error {
func (r *InitiatePayments) Validate() error {
if r == nil {
return merrors.InvalidArgument("request is required")
}
if err := r.PaymentBase.Validate(); err != nil {
return err
}
r.QuoteRef = strings.TrimSpace(r.QuoteRef)
r.IntentRef = strings.TrimSpace(r.IntentRef)
hasIntentRefsField := r.IntentRefs != nil
normalizedIntentRefs := make([]string, 0, len(r.IntentRefs))
seen := make(map[string]struct{}, len(r.IntentRefs))
for _, value := range r.IntentRefs {
intentRef := strings.TrimSpace(value)
if intentRef == "" {
return merrors.InvalidArgument("intentRefs must not contain empty values", "intentRefs")
}
if _, exists := seen[intentRef]; exists {
return merrors.InvalidArgument("intentRefs must contain unique values", "intentRefs")
}
seen[intentRef] = struct{}{}
normalizedIntentRefs = append(normalizedIntentRefs, intentRef)
}
if hasIntentRefsField && len(normalizedIntentRefs) == 0 {
return merrors.InvalidArgument("intentRefs must not be empty", "intentRefs")
}
r.IntentRefs = normalizedIntentRefs
if len(r.IntentRefs) == 0 {
r.IntentRefs = nil
}
if r.QuoteRef == "" {
return merrors.InvalidArgument("quoteRef is required", "quoteRef")
}
if r.IntentRef != "" && len(r.IntentRefs) > 0 {
return merrors.DataConflict("intentRef and intentRefs are mutually exclusive")
}
return nil
}

View File

@@ -27,3 +27,97 @@ func TestValidateQuoteIdempotency(t *testing.T) {
}
})
}
func TestInitiatePaymentsValidateIntentSelectors(t *testing.T) {
t.Run("accepts explicit intentRef", func(t *testing.T) {
req := &InitiatePayments{
PaymentBase: PaymentBase{IdempotencyKey: "idem-1"},
QuoteRef: "quote-1",
IntentRef: " intent-a ",
}
if err := req.Validate(); err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got, want := req.IntentRef, "intent-a"; got != want {
t.Fatalf("intentRef mismatch: got=%q want=%q", got, want)
}
if req.IntentRefs != nil {
t.Fatalf("expected nil intentRefs, got %#v", req.IntentRefs)
}
})
t.Run("accepts explicit intentRefs", func(t *testing.T) {
req := &InitiatePayments{
PaymentBase: PaymentBase{IdempotencyKey: "idem-1"},
QuoteRef: " quote-1 ",
IntentRefs: []string{" intent-a ", "intent-b"},
}
if err := req.Validate(); err != nil {
t.Fatalf("expected no error, got %v", err)
}
if got, want := req.QuoteRef, "quote-1"; got != want {
t.Fatalf("quoteRef mismatch: got=%q want=%q", got, want)
}
if got, want := len(req.IntentRefs), 2; got != want {
t.Fatalf("intentRefs length mismatch: got=%d want=%d", got, want)
}
if got, want := req.IntentRefs[0], "intent-a"; got != want {
t.Fatalf("intentRefs[0] mismatch: got=%q want=%q", got, want)
}
})
t.Run("rejects both intentRef and intentRefs", func(t *testing.T) {
req := &InitiatePayments{
PaymentBase: PaymentBase{IdempotencyKey: "idem-1"},
QuoteRef: "quote-1",
IntentRef: "intent-a",
IntentRefs: []string{"intent-b"},
}
if err := req.Validate(); err == nil {
t.Fatal("expected error")
}
})
t.Run("rejects empty intentRefs item", func(t *testing.T) {
req := &InitiatePayments{
PaymentBase: PaymentBase{IdempotencyKey: "idem-1"},
QuoteRef: "quote-1",
IntentRefs: []string{"intent-a", " "},
}
if err := req.Validate(); err == nil {
t.Fatal("expected error")
}
})
t.Run("rejects empty intentRefs list", func(t *testing.T) {
req := &InitiatePayments{
PaymentBase: PaymentBase{IdempotencyKey: "idem-1"},
QuoteRef: "quote-1",
IntentRefs: []string{},
}
if err := req.Validate(); err == nil {
t.Fatal("expected error")
}
})
t.Run("rejects duplicate intentRefs", func(t *testing.T) {
req := &InitiatePayments{
PaymentBase: PaymentBase{IdempotencyKey: "idem-1"},
QuoteRef: "quote-1",
IntentRefs: []string{"intent-a", " intent-a "},
}
if err := req.Validate(); err == nil {
t.Fatal("expected error")
}
})
t.Run("accepts no selectors for backward compatibility", func(t *testing.T) {
req := &InitiatePayments{
PaymentBase: PaymentBase{IdempotencyKey: "idem-1"},
QuoteRef: "quote-1",
}
if err := req.Validate(); err != nil {
t.Fatalf("expected no error, got %v", err)
}
})
}

View File

@@ -42,6 +42,7 @@ type FxQuote struct {
type PaymentQuote struct {
QuoteRef string `json:"quoteRef,omitempty"`
IntentRef string `json:"intentRef,omitempty"`
Amounts *QuoteAmounts `json:"amounts,omitempty"`
Fees *QuoteFees `json:"fees,omitempty"`
FxQuote *FxQuote `json:"fxQuote,omitempty"`
@@ -212,6 +213,7 @@ func toPaymentQuote(q *quotationv2.PaymentQuote) *PaymentQuote {
fees := toQuoteFees(q.GetFeeLines())
return &PaymentQuote{
QuoteRef: q.GetQuoteRef(),
IntentRef: strings.TrimSpace(q.GetIntentRef()),
Amounts: amounts,
Fees: fees,
FxQuote: toFxQuote(q.GetFxQuote()),

View File

@@ -4,6 +4,7 @@ import (
"testing"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
quotationv2 "github.com/tech/sendico/pkg/proto/payments/quotation/v2"
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
)
@@ -117,3 +118,19 @@ func TestToPaymentIgnoresHiddenFailures(t *testing.T) {
t.Fatalf("expected no visible operations, got=%d", len(dto.Operations))
}
}
func TestToPaymentQuote_MapsIntentRef(t *testing.T) {
dto := toPaymentQuote(&quotationv2.PaymentQuote{
QuoteRef: "quote-1",
IntentRef: "intent-1",
})
if dto == nil {
t.Fatal("expected non-nil quote dto")
}
if got, want := dto.QuoteRef, "quote-1"; got != want {
t.Fatalf("quote_ref mismatch: got=%q want=%q", got, want)
}
if got, want := dto.IntentRef, "intent-1"; got != want {
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
}
}

View File

@@ -1,6 +1,8 @@
package paymentapiimp
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"net/http"
"strings"
@@ -16,6 +18,11 @@ import (
"go.uber.org/zap"
)
const (
fanoutIdempotencyHashLen = 16
maxExecuteIdempotencyKey = 256
)
func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
orgRef, err := a.oph.GetRef(r)
if err != nil {
@@ -39,26 +46,100 @@ func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Acc
return response.BadPayload(a.logger, a.Name(), err)
}
intentSelectors, err := resolveExecutionIntentSelectors(payload, a.isLegacyMetadataIntentRefFallbackAllowed())
if err != nil {
return response.BadPayload(a.logger, a.Name(), err)
}
clientPaymentRef := metadataValue(payload.Metadata, "client_payment_ref")
baseIdempotencyKey := strings.TrimSpace(payload.IdempotencyKey)
quotationRef := strings.TrimSpace(payload.QuoteRef)
executeOne := func(idempotencyKey, intentRef string) (*orchestrationv2.Payment, error) {
req := &orchestrationv2.ExecutePaymentRequest{
Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey),
QuotationRef: strings.TrimSpace(payload.QuoteRef),
IntentRef: metadataValue(payload.Metadata, "intent_ref"),
ClientPaymentRef: metadataValue(payload.Metadata, "client_payment_ref"),
Meta: requestMeta(orgRef.Hex(), idempotencyKey),
QuotationRef: quotationRef,
IntentRef: strings.TrimSpace(intentRef),
ClientPaymentRef: clientPaymentRef,
}
resp, executeErr := a.execution.ExecutePayment(ctx, req)
if executeErr != nil {
return nil, executeErr
}
return resp.GetPayment(), nil
}
resp, err := a.execution.ExecutePayment(ctx, req)
payments := make([]*orchestrationv2.Payment, 0, max(1, len(intentSelectors)))
if len(payload.IntentRefs) > 0 {
for _, intentRef := range payload.IntentRefs {
payment, executeErr := executeOne(deriveFanoutIdempotencyKey(baseIdempotencyKey, intentRef), intentRef)
if executeErr != nil {
a.logger.Warn("Failed to initiate batch payments", zap.Error(executeErr), zap.String("organization_ref", orgRef.Hex()))
return grpcErrorResponse(a.logger, a.Name(), executeErr)
}
if payment != nil {
payments = append(payments, payment)
}
}
return sresponse.PaymentsResponse(a.logger, payments, token)
}
intentRef := ""
if len(intentSelectors) > 0 {
intentRef = intentSelectors[0]
}
payment, err := executeOne(baseIdempotencyKey, intentRef)
if err != nil {
a.logger.Warn("Failed to initiate batch payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
return grpcErrorResponse(a.logger, a.Name(), err)
}
payments := make([]*orchestrationv2.Payment, 0, 1)
if payment := resp.GetPayment(); payment != nil {
if payment != nil {
payments = append(payments, payment)
}
return sresponse.PaymentsResponse(a.logger, payments, token)
}
func resolveExecutionIntentSelectors(payload *srequest.InitiatePayments, allowLegacyMetadataIntentRef bool) ([]string, error) {
if payload == nil {
return nil, nil
}
if len(payload.IntentRefs) > 0 {
return append([]string(nil), payload.IntentRefs...), nil
}
if intentRef := strings.TrimSpace(payload.IntentRef); intentRef != "" {
return []string{intentRef}, nil
}
legacy := metadataValue(payload.Metadata, "intent_ref")
if legacy == "" {
return nil, nil
}
if allowLegacyMetadataIntentRef {
return []string{legacy}, nil
}
return nil, merrors.InvalidArgument("metadata.intent_ref is no longer supported; use intentRef or intentRefs", "metadata.intent_ref")
}
func deriveFanoutIdempotencyKey(baseIdempotencyKey, intentRef string) string {
baseIdempotencyKey = strings.TrimSpace(baseIdempotencyKey)
intentRef = strings.TrimSpace(intentRef)
if baseIdempotencyKey == "" || intentRef == "" {
return baseIdempotencyKey
}
sum := sha256.Sum256([]byte(intentRef))
hash := hex.EncodeToString(sum[:])
if len(hash) > fanoutIdempotencyHashLen {
hash = hash[:fanoutIdempotencyHashLen]
}
suffix := ":i:" + hash
if len(baseIdempotencyKey)+len(suffix) <= maxExecuteIdempotencyKey {
return baseIdempotencyKey + suffix
}
if len(suffix) >= maxExecuteIdempotencyKey {
return suffix[:maxExecuteIdempotencyKey]
}
prefixLen := maxExecuteIdempotencyKey - len(suffix)
return baseIdempotencyKey[:prefixLen] + suffix
}
func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments, error) {
defer r.Body.Close()
@@ -68,6 +149,7 @@ func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments,
}
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
payload.QuoteRef = strings.TrimSpace(payload.QuoteRef)
payload.IntentRef = strings.TrimSpace(payload.IntentRef)
if err := payload.Validate(); err != nil {
return nil, err

View File

@@ -0,0 +1,268 @@
package paymentapiimp
import (
"bytes"
"context"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
"github.com/go-chi/chi/v5"
"github.com/tech/sendico/pkg/auth"
"github.com/tech/sendico/pkg/merrors"
"github.com/tech/sendico/pkg/mlogger"
"github.com/tech/sendico/pkg/model"
"github.com/tech/sendico/pkg/mservice"
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
"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/v2/bson"
"go.uber.org/zap"
)
func TestInitiatePaymentsByQuote_FansOutByIntentRefs(t *testing.T) {
orgRef := bson.NewObjectID()
exec := &fakeExecutionClientForBatch{}
api := newBatchAPI(exec)
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","intentRefs":["intent-a","intent-b"]}`
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
if got, want := rr.Code, http.StatusOK; got != want {
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
}
if got, want := len(exec.executeReqs), 2; got != want {
t.Fatalf("execute calls mismatch: got=%d want=%d", got, want)
}
if got, want := exec.executeReqs[0].GetIntentRef(), "intent-a"; got != want {
t.Fatalf("intent_ref[0] mismatch: got=%q want=%q", got, want)
}
if got, want := exec.executeReqs[1].GetIntentRef(), "intent-b"; got != want {
t.Fatalf("intent_ref[1] mismatch: got=%q want=%q", got, want)
}
if got, want := exec.executeReqs[0].GetMeta().GetTrace().GetIdempotencyKey(), deriveFanoutIdempotencyKey("idem-batch", "intent-a"); got != want {
t.Fatalf("idempotency[0] mismatch: got=%q want=%q", got, want)
}
if got, want := exec.executeReqs[1].GetMeta().GetTrace().GetIdempotencyKey(), deriveFanoutIdempotencyKey("idem-batch", "intent-b"); got != want {
t.Fatalf("idempotency[1] mismatch: got=%q want=%q", got, want)
}
}
func TestInitiatePaymentsByQuote_UsesExplicitIntentRef(t *testing.T) {
orgRef := bson.NewObjectID()
exec := &fakeExecutionClientForBatch{}
api := newBatchAPI(exec)
body := `{"idempotencyKey":"idem-single","quoteRef":"quote-1","intentRef":"intent-x"}`
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
if got, want := rr.Code, http.StatusOK; got != want {
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
}
if got, want := len(exec.executeReqs), 1; got != want {
t.Fatalf("execute calls mismatch: got=%d want=%d", got, want)
}
if got, want := exec.executeReqs[0].GetIntentRef(), "intent-x"; got != want {
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
}
if got, want := exec.executeReqs[0].GetMeta().GetTrace().GetIdempotencyKey(), "idem-single"; got != want {
t.Fatalf("idempotency mismatch: got=%q want=%q", got, want)
}
}
func TestInitiatePaymentsByQuote_UsesLegacyMetadataIntentRefFallback(t *testing.T) {
orgRef := bson.NewObjectID()
exec := &fakeExecutionClientForBatch{}
api := newBatchAPIWithLegacyFallback(exec, true, time.Time{})
body := `{"idempotencyKey":"idem-legacy","quoteRef":"quote-1","metadata":{"intent_ref":"intent-legacy"}}`
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
if got, want := rr.Code, http.StatusOK; got != want {
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
}
if got, want := len(exec.executeReqs), 1; got != want {
t.Fatalf("execute calls mismatch: got=%d want=%d", got, want)
}
if got, want := exec.executeReqs[0].GetIntentRef(), "intent-legacy"; got != want {
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
}
}
func TestInitiatePaymentsByQuote_RejectsLegacyMetadataIntentRefFallbackByDefault(t *testing.T) {
orgRef := bson.NewObjectID()
exec := &fakeExecutionClientForBatch{}
api := newBatchAPI(exec)
body := `{"idempotencyKey":"idem-legacy","quoteRef":"quote-1","metadata":{"intent_ref":"intent-legacy"}}`
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
if got, want := rr.Code, http.StatusBadRequest; got != want {
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
}
if got := len(exec.executeReqs); got != 0 {
t.Fatalf("expected no execute calls, got=%d", got)
}
}
func TestInitiatePaymentsByQuote_RejectsLegacyMetadataIntentRefWhenDateGateExpired(t *testing.T) {
orgRef := bson.NewObjectID()
exec := &fakeExecutionClientForBatch{}
now := time.Date(2026, time.January, 10, 12, 0, 0, 0, time.UTC)
api := newBatchAPIWithLegacyFallback(exec, true, now.Add(-time.Minute))
api.clock = func() time.Time { return now }
body := `{"idempotencyKey":"idem-legacy","quoteRef":"quote-1","metadata":{"intent_ref":"intent-legacy"}}`
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
if got, want := rr.Code, http.StatusBadRequest; got != want {
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
}
if got := len(exec.executeReqs); got != 0 {
t.Fatalf("expected no execute calls, got=%d", got)
}
}
func TestDeriveFanoutIdempotencyKey_IsDeterministicAndBounded(t *testing.T) {
a := deriveFanoutIdempotencyKey("idem-1", "intent-a")
b := deriveFanoutIdempotencyKey("idem-1", "intent-a")
if got, want := a, b; got != want {
t.Fatalf("determinism mismatch: got=%q want=%q", got, want)
}
if a == "idem-1" {
t.Fatalf("expected derived key to differ from base")
}
c := deriveFanoutIdempotencyKey("idem-1", "intent-b")
if c == a {
t.Fatalf("expected different derived keys for different intents")
}
longBase := strings.Repeat("a", 400)
long := deriveFanoutIdempotencyKey(longBase, "intent-a")
if got, want := len(long), maxExecuteIdempotencyKey; got != want {
t.Fatalf("length mismatch: got=%d want=%d", got, want)
}
}
func TestResolveExecutionIntentSelectors_PrefersExplicitSelectors(t *testing.T) {
payload := &srequest.InitiatePayments{
IntentRefs: []string{"intent-a", "intent-b"},
IntentRef: "intent-single",
PaymentBase: srequest.PaymentBase{
Metadata: map[string]string{"intent_ref": "intent-legacy"},
},
}
got, err := resolveExecutionIntentSelectors(payload, false)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil || len(got) != 2 {
t.Fatalf("unexpected selectors: %#v", got)
}
if got[0] != "intent-a" || got[1] != "intent-b" {
t.Fatalf("unexpected selectors order/value: %#v", got)
}
}
func TestResolveExecutionIntentSelectors_RejectsLegacyMetadataSelectorWhenDisabled(t *testing.T) {
payload := &srequest.InitiatePayments{
PaymentBase: srequest.PaymentBase{
Metadata: map[string]string{"intent_ref": "intent-legacy"},
},
}
_, err := resolveExecutionIntentSelectors(payload, false)
if !errors.Is(err, merrors.ErrInvalidArg) {
t.Fatalf("expected invalid argument, got %v", err)
}
}
func TestResolveExecutionIntentSelectors_UsesLegacyMetadataSelectorWhenEnabled(t *testing.T) {
payload := &srequest.InitiatePayments{
PaymentBase: srequest.PaymentBase{
Metadata: map[string]string{"intent_ref": "intent-legacy"},
},
}
got, err := resolveExecutionIntentSelectors(payload, true)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got == nil || len(got) != 1 || got[0] != "intent-legacy" {
t.Fatalf("unexpected selectors: %#v", got)
}
}
func newBatchAPI(exec executionClient) *PaymentAPI {
return newBatchAPIWithLegacyFallback(exec, false, time.Time{})
}
func newBatchAPIWithLegacyFallback(exec executionClient, enabled bool, until time.Time) *PaymentAPI {
return &PaymentAPI{
logger: mlogger.Logger(zap.NewNop()),
execution: exec,
enf: fakeEnforcerForBatch{allowed: true},
oph: mutil.CreatePH(mservice.Organizations),
permissionRef: bson.NewObjectID(),
legacyMetadataIntentRefFallbackEnabled: enabled,
legacyMetadataIntentRefFallbackUntil: until,
clock: time.Now,
}
}
func invokeInitiatePaymentsByQuote(t *testing.T, api *PaymentAPI, orgRef bson.ObjectID, body string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/by-multiquote", bytes.NewBufferString(body))
routeCtx := chi.NewRouteContext()
routeCtx.URLParams.Add("organizations_ref", orgRef.Hex())
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
rr := httptest.NewRecorder()
handler := api.initiatePaymentsByQuote(req, &model.Account{}, &sresponse.TokenData{
Token: "token",
Expiration: time.Now().UTC().Add(time.Hour),
})
handler.ServeHTTP(rr, req)
return rr
}
type fakeExecutionClientForBatch struct {
executeReqs []*orchestrationv2.ExecutePaymentRequest
}
func (f *fakeExecutionClientForBatch) ExecutePayment(_ context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) {
f.executeReqs = append(f.executeReqs, req)
return &orchestrationv2.ExecutePaymentResponse{
Payment: &orchestrationv2.Payment{PaymentRef: bson.NewObjectID().Hex()},
}, nil
}
func (*fakeExecutionClientForBatch) ListPayments(context.Context, *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) {
return &orchestrationv2.ListPaymentsResponse{}, nil
}
func (*fakeExecutionClientForBatch) Close() error { return nil }
type fakeEnforcerForBatch struct {
allowed bool
}
func (f fakeEnforcerForBatch) Enforce(context.Context, bson.ObjectID, bson.ObjectID, bson.ObjectID, bson.ObjectID, model.Action) (bool, error) {
return f.allowed, nil
}
func (fakeEnforcerForBatch) EnforceBatch(context.Context, []model.PermissionBoundStorable, bson.ObjectID, model.Action) (map[bson.ObjectID]bool, error) {
return nil, nil
}
func (fakeEnforcerForBatch) GetRoles(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, error) {
return nil, nil
}
func (fakeEnforcerForBatch) GetPermissions(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, []model.Permission, error) {
return nil, nil, nil
}
var _ auth.Enforcer = (*fakeEnforcerForBatch)(nil)

View File

@@ -5,6 +5,7 @@ import (
"crypto/tls"
"fmt"
"os"
"strconv"
"strings"
"sync"
"time"
@@ -29,6 +30,11 @@ import (
"google.golang.org/grpc/credentials/insecure"
)
const (
envLegacyMetadataIntentRefFallbackEnabled = "PAYMENTS_LEGACY_METADATA_INTENT_REF_FALLBACK"
envLegacyMetadataIntentRefFallbackUntil = "PAYMENTS_LEGACY_METADATA_INTENT_REF_FALLBACK_UNTIL"
)
type executionClient interface {
ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error)
ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error)
@@ -52,6 +58,10 @@ type PaymentAPI struct {
refreshMu sync.RWMutex
refreshEvent *discovery.RefreshEvent
legacyMetadataIntentRefFallbackEnabled bool
legacyMetadataIntentRefFallbackUntil time.Time
clock func() time.Time
permissionRef bson.ObjectID
}
@@ -82,6 +92,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
logger: apiCtx.Logger().Named(mservice.Payments),
enf: apiCtx.Permissions().Enforcer(),
oph: mutil.CreatePH(mservice.Organizations),
clock: time.Now,
}
desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.Payments)
@@ -95,6 +106,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
p.logger.Error("Failed to initialize payment orchestrator client", zap.Error(err))
return nil, err
}
p.configureLegacyMetadataIntentRefFallback()
if err := p.initDiscoveryClient(apiCtx.Config()); err != nil {
p.logger.Warn("Failed to initialize discovery client", zap.Error(err))
}
@@ -290,3 +302,79 @@ func (a *PaymentAPI) initDiscoveryClient(cfg *eapi.Config) error {
}()
return nil
}
func (a *PaymentAPI) configureLegacyMetadataIntentRefFallback() {
enabled := false
enabledRaw := strings.TrimSpace(os.Getenv(envLegacyMetadataIntentRefFallbackEnabled))
if enabledRaw != "" {
parsed, err := strconv.ParseBool(enabledRaw)
if err != nil {
a.logger.Warn("Invalid legacy metadata intent_ref fallback flag, disabling fallback",
zap.String("env", envLegacyMetadataIntentRefFallbackEnabled),
zap.String("value", enabledRaw),
zap.Error(err),
)
} else {
enabled = parsed
}
}
until := time.Time{}
untilRaw := strings.TrimSpace(os.Getenv(envLegacyMetadataIntentRefFallbackUntil))
if untilRaw != "" {
parsed, err := parseLegacyMetadataIntentRefFallbackDeadline(untilRaw)
if err != nil {
a.logger.Warn("Invalid legacy metadata intent_ref fallback deadline, ignoring deadline",
zap.String("env", envLegacyMetadataIntentRefFallbackUntil),
zap.String("value", untilRaw),
zap.Error(err),
)
} else {
until = parsed
}
}
a.legacyMetadataIntentRefFallbackEnabled = enabled
a.legacyMetadataIntentRefFallbackUntil = until
if !enabled {
return
}
fields := []zap.Field{
zap.String("env_flag", envLegacyMetadataIntentRefFallbackEnabled),
zap.String("env_until", envLegacyMetadataIntentRefFallbackUntil),
}
if !until.IsZero() {
fields = append(fields, zap.Time("until_utc", until.UTC()))
}
a.logger.Warn("Legacy metadata.intent_ref fallback is enabled for /by-multiquote", fields...)
}
func (a *PaymentAPI) isLegacyMetadataIntentRefFallbackAllowed() bool {
if a == nil || !a.legacyMetadataIntentRefFallbackEnabled {
return false
}
if a.legacyMetadataIntentRefFallbackUntil.IsZero() {
return true
}
now := time.Now().UTC()
if a.clock != nil {
now = a.clock().UTC()
}
return now.Before(a.legacyMetadataIntentRefFallbackUntil.UTC())
}
func parseLegacyMetadataIntentRefFallbackDeadline(value string) (time.Time, error) {
raw := strings.TrimSpace(value)
if raw == "" {
return time.Time{}, merrors.InvalidArgument("deadline is required")
}
if ts, err := time.Parse(time.RFC3339, raw); err == nil {
return ts.UTC(), nil
}
if date, err := time.Parse("2006-01-02", raw); err == nil {
// Date-only values are treated as inclusive; disable fallback at the next UTC midnight.
return date.UTC().Add(24 * time.Hour), nil
}
return time.Time{}, merrors.InvalidArgument("deadline must be RFC3339 or YYYY-MM-DD")
}

View File

@@ -0,0 +1,82 @@
package paymentapiimp
import (
"testing"
"time"
)
func TestParseLegacyMetadataIntentRefFallbackDeadline(t *testing.T) {
t.Run("parses RFC3339", func(t *testing.T) {
got, err := parseLegacyMetadataIntentRefFallbackDeadline("2026-02-26T12:00:00Z")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := time.Date(2026, time.February, 26, 12, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("deadline mismatch: got=%s want=%s", got.UTC(), want.UTC())
}
})
t.Run("parses date-only as inclusive UTC day", func(t *testing.T) {
got, err := parseLegacyMetadataIntentRefFallbackDeadline("2026-02-26")
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
want := time.Date(2026, time.February, 27, 0, 0, 0, 0, time.UTC)
if !got.Equal(want) {
t.Fatalf("deadline mismatch: got=%s want=%s", got.UTC(), want.UTC())
}
})
t.Run("rejects invalid format", func(t *testing.T) {
if _, err := parseLegacyMetadataIntentRefFallbackDeadline("26-02-2026"); err == nil {
t.Fatal("expected error")
}
})
}
func TestIsLegacyMetadataIntentRefFallbackAllowed(t *testing.T) {
now := time.Date(2026, time.February, 26, 12, 0, 0, 0, time.UTC)
t.Run("disabled", func(t *testing.T) {
api := &PaymentAPI{
legacyMetadataIntentRefFallbackEnabled: false,
clock: func() time.Time { return now },
}
if api.isLegacyMetadataIntentRefFallbackAllowed() {
t.Fatal("expected disabled fallback")
}
})
t.Run("enabled without deadline", func(t *testing.T) {
api := &PaymentAPI{
legacyMetadataIntentRefFallbackEnabled: true,
clock: func() time.Time { return now },
}
if !api.isLegacyMetadataIntentRefFallbackAllowed() {
t.Fatal("expected enabled fallback")
}
})
t.Run("enabled with future deadline", func(t *testing.T) {
api := &PaymentAPI{
legacyMetadataIntentRefFallbackEnabled: true,
legacyMetadataIntentRefFallbackUntil: now.Add(time.Minute),
clock: func() time.Time { return now },
}
if !api.isLegacyMetadataIntentRefFallbackAllowed() {
t.Fatal("expected enabled fallback before deadline")
}
})
t.Run("enabled with past deadline", func(t *testing.T) {
api := &PaymentAPI{
legacyMetadataIntentRefFallbackEnabled: true,
legacyMetadataIntentRefFallbackUntil: now.Add(-time.Minute),
clock: func() time.Time { return now },
}
if api.isLegacyMetadataIntentRefFallbackAllowed() {
t.Fatal("expected disabled fallback after deadline")
}
})
}

View File

@@ -1,13 +1,16 @@
import 'package:pshared/api/requests/payment/base.dart';
class InitiatePaymentsRequest extends PaymentBaseRequest {
final String quoteRef;
final String? intentRef;
final List<String>? intentRefs;
const InitiatePaymentsRequest({
required super.idempotencyKey,
super.metadata,
required this.quoteRef,
this.intentRef,
this.intentRefs,
});
factory InitiatePaymentsRequest.fromJson(Map<String, dynamic> json) {
@@ -17,6 +20,10 @@ class InitiatePaymentsRequest extends PaymentBaseRequest {
(key, value) => MapEntry(key, value as String),
),
quoteRef: json['quoteRef'] as String,
intentRef: json['intentRef'] as String?,
intentRefs: (json['intentRefs'] as List<dynamic>?)
?.map((value) => value as String)
.toList(),
);
}
@@ -26,6 +33,8 @@ class InitiatePaymentsRequest extends PaymentBaseRequest {
'idempotencyKey': idempotencyKey,
'metadata': metadata,
'quoteRef': quoteRef,
if (intentRef != null) 'intentRef': intentRef,
if (intentRefs != null) 'intentRefs': intentRefs,
};
}
}

View File

@@ -9,11 +9,18 @@ part 'payment_quote.g.dart';
@JsonSerializable()
class PaymentQuoteDTO {
final String? quoteRef;
final String? intentRef;
final QuoteAmountsDTO? amounts;
final QuoteFeesDTO? fees;
final FxQuoteDTO? fxQuote;
const PaymentQuoteDTO({this.quoteRef, this.amounts, this.fees, this.fxQuote});
const PaymentQuoteDTO({
this.quoteRef,
this.intentRef,
this.amounts,
this.fees,
this.fxQuote,
});
factory PaymentQuoteDTO.fromJson(Map<String, dynamic> json) =>
_$PaymentQuoteDTOFromJson(json);

View File

@@ -7,6 +7,7 @@ import 'package:pshared/models/payment/quote/quote.dart';
extension PaymentQuoteDTOMapper on PaymentQuoteDTO {
PaymentQuote toDomain({String? idempotencyKey}) => PaymentQuote(
quoteRef: quoteRef,
intentRef: intentRef,
idempotencyKey: idempotencyKey,
amounts: amounts?.toDomain(),
fees: fees?.toDomain(),
@@ -17,6 +18,7 @@ extension PaymentQuoteDTOMapper on PaymentQuoteDTO {
extension PaymentQuoteMapper on PaymentQuote {
PaymentQuoteDTO toDTO() => PaymentQuoteDTO(
quoteRef: quoteRef,
intentRef: intentRef,
amounts: amounts?.toDTO(),
fees: fees?.toDTO(),
fxQuote: fxQuote?.toDTO(),

View File

@@ -4,6 +4,7 @@ import 'package:pshared/models/payment/quote/fees.dart';
class PaymentQuote {
final String? quoteRef;
final String? intentRef;
final String? idempotencyKey;
final QuoteAmounts? amounts;
final QuoteFees? fees;
@@ -11,6 +12,7 @@ class PaymentQuote {
const PaymentQuote({
required this.quoteRef,
required this.intentRef,
required this.idempotencyKey,
required this.amounts,
required this.fees,

View File

@@ -7,7 +7,6 @@ import 'package:pshared/provider/resource.dart';
import 'package:pshared/service/payment/multiple.dart';
import 'package:pshared/utils/exception.dart';
class MultiPaymentProvider extends ChangeNotifier {
late OrganizationsProvider _organization;
late MultiQuotationProvider _quotation;
@@ -31,6 +30,8 @@ class MultiPaymentProvider extends ChangeNotifier {
Future<List<Payment>> pay({
String? idempotencyKey,
Map<String, String>? metadata,
String? intentRef,
List<String>? intentRefs,
}) async {
if (!_organization.isOrganizationSet) {
throw StateError('Organization is not set');
@@ -53,6 +54,8 @@ class MultiPaymentProvider extends ChangeNotifier {
quoteRef,
idempotencyKey: idempotencyKey,
metadata: metadata,
intentRef: intentRef,
intentRefs: intentRefs,
);
_setResource(

View File

@@ -13,7 +13,6 @@ import 'package:pshared/models/payment/quote/quotes.dart';
import 'package:pshared/service/authorization/service.dart';
import 'package:pshared/service/services.dart';
class MultiplePaymentsService {
static final _logger = Logger('service.payment.multiple');
static const String _objectType = Services.payments;
@@ -38,6 +37,8 @@ class MultiplePaymentsService {
String quoteRef, {
String? idempotencyKey,
Map<String, String>? metadata,
String? intentRef,
List<String>? intentRefs,
}) async {
_logger.fine(
'Executing multiple payments for quote $quoteRef in $organizationRef',
@@ -46,6 +47,8 @@ class MultiplePaymentsService {
idempotencyKey: idempotencyKey ?? const Uuid().v4(),
quoteRef: quoteRef,
metadata: metadata,
intentRef: intentRef,
intentRefs: intentRefs,
);
final response = await AuthorizationService.getPOSTResponse(

View File

@@ -114,6 +114,7 @@ void main() {
'idempotencyKey': 'idem-1',
'quote': {
'quoteRef': 'q-1',
'intentRef': 'intent-1',
'amounts': {
'sourcePrincipal': {'amount': '10', 'currency': 'USDT'},
'sourceDebitTotal': {'amount': '10.75', 'currency': 'USDT'},
@@ -148,6 +149,7 @@ void main() {
});
expect(response.quote.fxQuote?.pricedAtUnixMs, equals(1771945907000));
expect(response.quote.intentRef, equals('intent-1'));
expect(response.quote.amounts?.sourceDebitTotal?.amount, equals('10.75'));
expect(response.quote.fees?.lines?.length, equals(1));
});
@@ -174,16 +176,35 @@ void main() {
final request = InitiatePaymentsRequest(
idempotencyKey: 'idem-3',
quoteRef: 'q-2',
intentRefs: const ['intent-a', 'intent-b'],
metadata: const {'client_payment_ref': 'cp-1'},
);
final json = request.toJson();
expect(json['idempotencyKey'], equals('idem-3'));
expect(json['quoteRef'], equals('q-2'));
expect(json['intentRefs'], equals(const ['intent-a', 'intent-b']));
expect(
(json['metadata'] as Map<String, dynamic>)['client_payment_ref'],
equals('cp-1'),
);
});
test(
'initiate multi payments request supports single intentRef selector',
() {
final request = InitiatePaymentsRequest(
idempotencyKey: 'idem-4',
quoteRef: 'q-2',
intentRef: 'intent-single',
);
final json = request.toJson();
expect(json['idempotencyKey'], equals('idem-4'));
expect(json['quoteRef'], equals('q-2'));
expect(json['intentRef'], equals('intent-single'));
expect(json.containsKey('intentRefs'), isFalse);
},
);
});
}

View File

@@ -188,6 +188,7 @@ class MultiplePayoutsProvider extends ChangeNotifier {
try {
_setState(MultiplePayoutsState.sending);
_error = null;
final intentRefs = _quotedIntentRefs();
final result = await payment.pay(
metadata: <String, String>{
@@ -197,6 +198,7 @@ class MultiplePayoutsProvider extends ChangeNotifier {
'upload_rows': _rows.length.toString(),
...?_uploadAmountMetadata(),
},
intentRefs: intentRefs.isEmpty ? null : intentRefs,
);
_sentCount = result.length;
@@ -272,6 +274,20 @@ class MultiplePayoutsProvider extends ChangeNotifier {
List<PaymentQuote> _quoteItems() =>
_quotation?.quotation?.items ?? const <PaymentQuote>[];
List<String> _quotedIntentRefs() {
final seen = <String>{};
final intentRefs = <String>[];
for (final quote in _quoteItems()) {
final intentRef = (quote.intentRef ?? '').trim();
if (intentRef.isEmpty || seen.contains(intentRef)) {
continue;
}
seen.add(intentRef);
intentRefs.add(intentRef);
}
return intentRefs;
}
@override
void dispose() {
_quotation?.removeListener(_onQuotationChanged);