diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go index dd0c555e..f4d4b7e3 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go @@ -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 "" diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go index 82a3f38f..1bbed458 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go @@ -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{ diff --git a/api/server/interface/api/srequest/payment.go b/api/server/interface/api/srequest/payment.go index 9b835318..d8e2ae5f 100644 --- a/api/server/interface/api/srequest/payment.go +++ b/api/server/interface/api/srequest/payment.go @@ -107,15 +107,48 @@ func (r InitiatePayment) Validate() error { type InitiatePayments struct { PaymentBase `json:",inline"` - QuoteRef string `json:"quoteRef,omitempty"` + 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 } diff --git a/api/server/interface/api/srequest/payment_validate_test.go b/api/server/interface/api/srequest/payment_validate_test.go index 3a801cef..9517f127 100644 --- a/api/server/interface/api/srequest/payment_validate_test.go +++ b/api/server/interface/api/srequest/payment_validate_test.go @@ -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) + } + }) +} diff --git a/api/server/interface/api/sresponse/payment.go b/api/server/interface/api/sresponse/payment.go index 1aa4c4a7..e8ca3e83 100644 --- a/api/server/interface/api/sresponse/payment.go +++ b/api/server/interface/api/sresponse/payment.go @@ -41,10 +41,11 @@ type FxQuote struct { } type PaymentQuote struct { - QuoteRef string `json:"quoteRef,omitempty"` - Amounts *QuoteAmounts `json:"amounts,omitempty"` - Fees *QuoteFees `json:"fees,omitempty"` - FxQuote *FxQuote `json:"fxQuote,omitempty"` + 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"` } type QuoteAmounts struct { @@ -211,10 +212,11 @@ func toPaymentQuote(q *quotationv2.PaymentQuote) *PaymentQuote { amounts := toQuoteAmounts(q) fees := toQuoteFees(q.GetFeeLines()) return &PaymentQuote{ - QuoteRef: q.GetQuoteRef(), - Amounts: amounts, - Fees: fees, - FxQuote: toFxQuote(q.GetFxQuote()), + QuoteRef: q.GetQuoteRef(), + IntentRef: strings.TrimSpace(q.GetIntentRef()), + Amounts: amounts, + Fees: fees, + FxQuote: toFxQuote(q.GetFxQuote()), } } diff --git a/api/server/interface/api/sresponse/payment_test.go b/api/server/interface/api/sresponse/payment_test.go index 12883537..a6de4f37 100644 --- a/api/server/interface/api/sresponse/payment_test.go +++ b/api/server/interface/api/sresponse/payment_test.go @@ -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("ationv2.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) + } +} diff --git a/api/server/internal/server/paymentapiimp/paybatch.go b/api/server/internal/server/paymentapiimp/paybatch.go index 27db08e1..b98d9b44 100644 --- a/api/server/internal/server/paymentapiimp/paybatch.go +++ b/api/server/internal/server/paymentapiimp/paybatch.go @@ -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) } - 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"), + 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(), 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 diff --git a/api/server/internal/server/paymentapiimp/paybatch_test.go b/api/server/internal/server/paymentapiimp/paybatch_test.go new file mode 100644 index 00000000..bfdadfa2 --- /dev/null +++ b/api/server/internal/server/paymentapiimp/paybatch_test.go @@ -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) diff --git a/api/server/internal/server/paymentapiimp/service.go b/api/server/internal/server/paymentapiimp/service.go index 1bdacbc5..beb9ea42 100644 --- a/api/server/internal/server/paymentapiimp/service.go +++ b/api/server/internal/server/paymentapiimp/service.go @@ -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") +} diff --git a/api/server/internal/server/paymentapiimp/service_legacy_fallback_test.go b/api/server/internal/server/paymentapiimp/service_legacy_fallback_test.go new file mode 100644 index 00000000..9a06da07 --- /dev/null +++ b/api/server/internal/server/paymentapiimp/service_legacy_fallback_test.go @@ -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") + } + }) +} diff --git a/frontend/pshared/lib/api/requests/payment/initiate_payments.dart b/frontend/pshared/lib/api/requests/payment/initiate_payments.dart index ed4349ca..3e628c9b 100644 --- a/frontend/pshared/lib/api/requests/payment/initiate_payments.dart +++ b/frontend/pshared/lib/api/requests/payment/initiate_payments.dart @@ -1,13 +1,16 @@ import 'package:pshared/api/requests/payment/base.dart'; - class InitiatePaymentsRequest extends PaymentBaseRequest { final String quoteRef; + final String? intentRef; + final List? intentRefs; const InitiatePaymentsRequest({ required super.idempotencyKey, super.metadata, required this.quoteRef, + this.intentRef, + this.intentRefs, }); factory InitiatePaymentsRequest.fromJson(Map 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?) + ?.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, }; } } diff --git a/frontend/pshared/lib/data/dto/payment/payment_quote.dart b/frontend/pshared/lib/data/dto/payment/payment_quote.dart index 326b979a..e125368e 100644 --- a/frontend/pshared/lib/data/dto/payment/payment_quote.dart +++ b/frontend/pshared/lib/data/dto/payment/payment_quote.dart @@ -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 json) => _$PaymentQuoteDTOFromJson(json); diff --git a/frontend/pshared/lib/data/mapper/payment/quote.dart b/frontend/pshared/lib/data/mapper/payment/quote.dart index e3decf77..e5f76298 100644 --- a/frontend/pshared/lib/data/mapper/payment/quote.dart +++ b/frontend/pshared/lib/data/mapper/payment/quote.dart @@ -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(), diff --git a/frontend/pshared/lib/models/payment/quote/quote.dart b/frontend/pshared/lib/models/payment/quote/quote.dart index ecf3e5bb..5ebde9c2 100644 --- a/frontend/pshared/lib/models/payment/quote/quote.dart +++ b/frontend/pshared/lib/models/payment/quote/quote.dart @@ -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, diff --git a/frontend/pshared/lib/provider/payment/multiple/provider.dart b/frontend/pshared/lib/provider/payment/multiple/provider.dart index 9c0cd15c..59614155 100644 --- a/frontend/pshared/lib/provider/payment/multiple/provider.dart +++ b/frontend/pshared/lib/provider/payment/multiple/provider.dart @@ -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> pay({ String? idempotencyKey, Map? metadata, + String? intentRef, + List? 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( diff --git a/frontend/pshared/lib/service/payment/multiple.dart b/frontend/pshared/lib/service/payment/multiple.dart index a181e68d..7c3ea44d 100644 --- a/frontend/pshared/lib/service/payment/multiple.dart +++ b/frontend/pshared/lib/service/payment/multiple.dart @@ -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? metadata, + String? intentRef, + List? 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( diff --git a/frontend/pshared/test/payment/request_dto_format_test.dart b/frontend/pshared/test/payment/request_dto_format_test.dart index 9d6ce40e..38eb231b 100644 --- a/frontend/pshared/test/payment/request_dto_format_test.dart +++ b/frontend/pshared/test/payment/request_dto_format_test.dart @@ -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)['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); + }, + ); }); } diff --git a/frontend/pweb/lib/providers/multiple_payouts.dart b/frontend/pweb/lib/providers/multiple_payouts.dart index b497b8f6..1957e64a 100644 --- a/frontend/pweb/lib/providers/multiple_payouts.dart +++ b/frontend/pweb/lib/providers/multiple_payouts.dart @@ -188,6 +188,7 @@ class MultiplePayoutsProvider extends ChangeNotifier { try { _setState(MultiplePayoutsState.sending); _error = null; + final intentRefs = _quotedIntentRefs(); final result = await payment.pay( metadata: { @@ -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 _quoteItems() => _quotation?.quotation?.items ?? const []; + List _quotedIntentRefs() { + final seen = {}; + final intentRefs = []; + 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);