Orchestration / payments v2 #554
@@ -205,7 +205,7 @@ func (s *svc) resolveAndPlan(ctx context.Context, requestCtx *reqval.Ctx) (*qsna
|
|||||||
IntentRef: requestCtx.IntentRef,
|
IntentRef: requestCtx.IntentRef,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, err
|
return nil, nil, remapResolveError(err)
|
||||||
}
|
}
|
||||||
graph, err := s.planner.Compile(xplan.Input{
|
graph, err := s.planner.Compile(xplan.Input{
|
||||||
IntentSnapshot: resolved.IntentSnapshot,
|
IntentSnapshot: resolved.IntentSnapshot,
|
||||||
@@ -269,6 +269,15 @@ func remapIdempotencyError(err error) error {
|
|||||||
return err
|
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 {
|
func mustFingerprint(idemSvc idem.Service, requestCtx *reqval.Ctx) string {
|
||||||
if idemSvc == nil || requestCtx == nil {
|
if idemSvc == nil || requestCtx == nil {
|
||||||
return ""
|
return ""
|
||||||
|
|||||||
@@ -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) {
|
func TestExecutePayment_RetryThenSuccess(t *testing.T) {
|
||||||
env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
env := newTestEnv(t, func(_ string, req sexec.StepRequest) (*sexec.ExecuteOutput, error) {
|
||||||
if req.StepExecution.Attempt == 1 {
|
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 {
|
func buildLedgerRoute() *paymenttypes.QuoteRouteSpecification {
|
||||||
return &paymenttypes.QuoteRouteSpecification{
|
return &paymenttypes.QuoteRouteSpecification{
|
||||||
Hops: []*paymenttypes.QuoteRouteHop{
|
Hops: []*paymenttypes.QuoteRouteHop{
|
||||||
|
|||||||
@@ -108,14 +108,47 @@ func (r InitiatePayment) Validate() error {
|
|||||||
type InitiatePayments struct {
|
type InitiatePayments struct {
|
||||||
PaymentBase `json:",inline"`
|
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 {
|
if err := r.PaymentBase.Validate(); err != nil {
|
||||||
return err
|
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 == "" {
|
if r.QuoteRef == "" {
|
||||||
return merrors.InvalidArgument("quoteRef is required", "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
|
return nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ type FxQuote struct {
|
|||||||
|
|
||||||
type PaymentQuote struct {
|
type PaymentQuote struct {
|
||||||
QuoteRef string `json:"quoteRef,omitempty"`
|
QuoteRef string `json:"quoteRef,omitempty"`
|
||||||
|
IntentRef string `json:"intentRef,omitempty"`
|
||||||
Amounts *QuoteAmounts `json:"amounts,omitempty"`
|
Amounts *QuoteAmounts `json:"amounts,omitempty"`
|
||||||
Fees *QuoteFees `json:"fees,omitempty"`
|
Fees *QuoteFees `json:"fees,omitempty"`
|
||||||
FxQuote *FxQuote `json:"fxQuote,omitempty"`
|
FxQuote *FxQuote `json:"fxQuote,omitempty"`
|
||||||
@@ -212,6 +213,7 @@ func toPaymentQuote(q *quotationv2.PaymentQuote) *PaymentQuote {
|
|||||||
fees := toQuoteFees(q.GetFeeLines())
|
fees := toQuoteFees(q.GetFeeLines())
|
||||||
return &PaymentQuote{
|
return &PaymentQuote{
|
||||||
QuoteRef: q.GetQuoteRef(),
|
QuoteRef: q.GetQuoteRef(),
|
||||||
|
IntentRef: strings.TrimSpace(q.GetIntentRef()),
|
||||||
Amounts: amounts,
|
Amounts: amounts,
|
||||||
Fees: fees,
|
Fees: fees,
|
||||||
FxQuote: toFxQuote(q.GetFxQuote()),
|
FxQuote: toFxQuote(q.GetFxQuote()),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
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"
|
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))
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
package paymentapiimp
|
package paymentapiimp
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@@ -16,6 +18,11 @@ import (
|
|||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
fanoutIdempotencyHashLen = 16
|
||||||
|
maxExecuteIdempotencyKey = 256
|
||||||
|
)
|
||||||
|
|
||||||
func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||||
orgRef, err := a.oph.GetRef(r)
|
orgRef, err := a.oph.GetRef(r)
|
||||||
if err != nil {
|
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)
|
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{
|
req := &orchestrationv2.ExecutePaymentRequest{
|
||||||
Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey),
|
Meta: requestMeta(orgRef.Hex(), idempotencyKey),
|
||||||
QuotationRef: strings.TrimSpace(payload.QuoteRef),
|
QuotationRef: quotationRef,
|
||||||
IntentRef: metadataValue(payload.Metadata, "intent_ref"),
|
IntentRef: strings.TrimSpace(intentRef),
|
||||||
ClientPaymentRef: metadataValue(payload.Metadata, "client_payment_ref"),
|
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 {
|
if err != nil {
|
||||||
a.logger.Warn("Failed to initiate batch payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
|
a.logger.Warn("Failed to initiate batch payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
|
||||||
return grpcErrorResponse(a.logger, a.Name(), err)
|
return grpcErrorResponse(a.logger, a.Name(), err)
|
||||||
}
|
}
|
||||||
|
if payment != nil {
|
||||||
payments := make([]*orchestrationv2.Payment, 0, 1)
|
|
||||||
if payment := resp.GetPayment(); payment != nil {
|
|
||||||
payments = append(payments, payment)
|
payments = append(payments, payment)
|
||||||
}
|
}
|
||||||
return sresponse.PaymentsResponse(a.logger, payments, token)
|
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) {
|
func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments, error) {
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
|
||||||
@@ -68,6 +149,7 @@ func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments,
|
|||||||
}
|
}
|
||||||
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
|
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
|
||||||
payload.QuoteRef = strings.TrimSpace(payload.QuoteRef)
|
payload.QuoteRef = strings.TrimSpace(payload.QuoteRef)
|
||||||
|
payload.IntentRef = strings.TrimSpace(payload.IntentRef)
|
||||||
|
|
||||||
if err := payload.Validate(); err != nil {
|
if err := payload.Validate(); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
268
api/server/internal/server/paymentapiimp/paybatch_test.go
Normal file
268
api/server/internal/server/paymentapiimp/paybatch_test.go
Normal 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)
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -29,6 +30,11 @@ import (
|
|||||||
"google.golang.org/grpc/credentials/insecure"
|
"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 {
|
type executionClient interface {
|
||||||
ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error)
|
ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error)
|
||||||
ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error)
|
ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error)
|
||||||
@@ -52,6 +58,10 @@ type PaymentAPI struct {
|
|||||||
refreshMu sync.RWMutex
|
refreshMu sync.RWMutex
|
||||||
refreshEvent *discovery.RefreshEvent
|
refreshEvent *discovery.RefreshEvent
|
||||||
|
|
||||||
|
legacyMetadataIntentRefFallbackEnabled bool
|
||||||
|
legacyMetadataIntentRefFallbackUntil time.Time
|
||||||
|
clock func() time.Time
|
||||||
|
|
||||||
permissionRef bson.ObjectID
|
permissionRef bson.ObjectID
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,6 +92,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
|
|||||||
logger: apiCtx.Logger().Named(mservice.Payments),
|
logger: apiCtx.Logger().Named(mservice.Payments),
|
||||||
enf: apiCtx.Permissions().Enforcer(),
|
enf: apiCtx.Permissions().Enforcer(),
|
||||||
oph: mutil.CreatePH(mservice.Organizations),
|
oph: mutil.CreatePH(mservice.Organizations),
|
||||||
|
clock: time.Now,
|
||||||
}
|
}
|
||||||
|
|
||||||
desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.Payments)
|
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))
|
p.logger.Error("Failed to initialize payment orchestrator client", zap.Error(err))
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
p.configureLegacyMetadataIntentRefFallback()
|
||||||
if err := p.initDiscoveryClient(apiCtx.Config()); err != nil {
|
if err := p.initDiscoveryClient(apiCtx.Config()); err != nil {
|
||||||
p.logger.Warn("Failed to initialize discovery client", zap.Error(err))
|
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
|
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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -1,13 +1,16 @@
|
|||||||
import 'package:pshared/api/requests/payment/base.dart';
|
import 'package:pshared/api/requests/payment/base.dart';
|
||||||
|
|
||||||
|
|
||||||
class InitiatePaymentsRequest extends PaymentBaseRequest {
|
class InitiatePaymentsRequest extends PaymentBaseRequest {
|
||||||
final String quoteRef;
|
final String quoteRef;
|
||||||
|
final String? intentRef;
|
||||||
|
final List<String>? intentRefs;
|
||||||
|
|
||||||
const InitiatePaymentsRequest({
|
const InitiatePaymentsRequest({
|
||||||
required super.idempotencyKey,
|
required super.idempotencyKey,
|
||||||
super.metadata,
|
super.metadata,
|
||||||
required this.quoteRef,
|
required this.quoteRef,
|
||||||
|
this.intentRef,
|
||||||
|
this.intentRefs,
|
||||||
});
|
});
|
||||||
|
|
||||||
factory InitiatePaymentsRequest.fromJson(Map<String, dynamic> json) {
|
factory InitiatePaymentsRequest.fromJson(Map<String, dynamic> json) {
|
||||||
@@ -17,6 +20,10 @@ class InitiatePaymentsRequest extends PaymentBaseRequest {
|
|||||||
(key, value) => MapEntry(key, value as String),
|
(key, value) => MapEntry(key, value as String),
|
||||||
),
|
),
|
||||||
quoteRef: json['quoteRef'] 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,
|
'idempotencyKey': idempotencyKey,
|
||||||
'metadata': metadata,
|
'metadata': metadata,
|
||||||
'quoteRef': quoteRef,
|
'quoteRef': quoteRef,
|
||||||
|
if (intentRef != null) 'intentRef': intentRef,
|
||||||
|
if (intentRefs != null) 'intentRefs': intentRefs,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,11 +9,18 @@ part 'payment_quote.g.dart';
|
|||||||
@JsonSerializable()
|
@JsonSerializable()
|
||||||
class PaymentQuoteDTO {
|
class PaymentQuoteDTO {
|
||||||
final String? quoteRef;
|
final String? quoteRef;
|
||||||
|
final String? intentRef;
|
||||||
final QuoteAmountsDTO? amounts;
|
final QuoteAmountsDTO? amounts;
|
||||||
final QuoteFeesDTO? fees;
|
final QuoteFeesDTO? fees;
|
||||||
final FxQuoteDTO? fxQuote;
|
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) =>
|
factory PaymentQuoteDTO.fromJson(Map<String, dynamic> json) =>
|
||||||
_$PaymentQuoteDTOFromJson(json);
|
_$PaymentQuoteDTOFromJson(json);
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'package:pshared/models/payment/quote/quote.dart';
|
|||||||
extension PaymentQuoteDTOMapper on PaymentQuoteDTO {
|
extension PaymentQuoteDTOMapper on PaymentQuoteDTO {
|
||||||
PaymentQuote toDomain({String? idempotencyKey}) => PaymentQuote(
|
PaymentQuote toDomain({String? idempotencyKey}) => PaymentQuote(
|
||||||
quoteRef: quoteRef,
|
quoteRef: quoteRef,
|
||||||
|
intentRef: intentRef,
|
||||||
idempotencyKey: idempotencyKey,
|
idempotencyKey: idempotencyKey,
|
||||||
amounts: amounts?.toDomain(),
|
amounts: amounts?.toDomain(),
|
||||||
fees: fees?.toDomain(),
|
fees: fees?.toDomain(),
|
||||||
@@ -17,6 +18,7 @@ extension PaymentQuoteDTOMapper on PaymentQuoteDTO {
|
|||||||
extension PaymentQuoteMapper on PaymentQuote {
|
extension PaymentQuoteMapper on PaymentQuote {
|
||||||
PaymentQuoteDTO toDTO() => PaymentQuoteDTO(
|
PaymentQuoteDTO toDTO() => PaymentQuoteDTO(
|
||||||
quoteRef: quoteRef,
|
quoteRef: quoteRef,
|
||||||
|
intentRef: intentRef,
|
||||||
amounts: amounts?.toDTO(),
|
amounts: amounts?.toDTO(),
|
||||||
fees: fees?.toDTO(),
|
fees: fees?.toDTO(),
|
||||||
fxQuote: fxQuote?.toDTO(),
|
fxQuote: fxQuote?.toDTO(),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import 'package:pshared/models/payment/quote/fees.dart';
|
|||||||
|
|
||||||
class PaymentQuote {
|
class PaymentQuote {
|
||||||
final String? quoteRef;
|
final String? quoteRef;
|
||||||
|
final String? intentRef;
|
||||||
final String? idempotencyKey;
|
final String? idempotencyKey;
|
||||||
final QuoteAmounts? amounts;
|
final QuoteAmounts? amounts;
|
||||||
final QuoteFees? fees;
|
final QuoteFees? fees;
|
||||||
@@ -11,6 +12,7 @@ class PaymentQuote {
|
|||||||
|
|
||||||
const PaymentQuote({
|
const PaymentQuote({
|
||||||
required this.quoteRef,
|
required this.quoteRef,
|
||||||
|
required this.intentRef,
|
||||||
required this.idempotencyKey,
|
required this.idempotencyKey,
|
||||||
required this.amounts,
|
required this.amounts,
|
||||||
required this.fees,
|
required this.fees,
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ import 'package:pshared/provider/resource.dart';
|
|||||||
import 'package:pshared/service/payment/multiple.dart';
|
import 'package:pshared/service/payment/multiple.dart';
|
||||||
import 'package:pshared/utils/exception.dart';
|
import 'package:pshared/utils/exception.dart';
|
||||||
|
|
||||||
|
|
||||||
class MultiPaymentProvider extends ChangeNotifier {
|
class MultiPaymentProvider extends ChangeNotifier {
|
||||||
late OrganizationsProvider _organization;
|
late OrganizationsProvider _organization;
|
||||||
late MultiQuotationProvider _quotation;
|
late MultiQuotationProvider _quotation;
|
||||||
@@ -31,6 +30,8 @@ class MultiPaymentProvider extends ChangeNotifier {
|
|||||||
Future<List<Payment>> pay({
|
Future<List<Payment>> pay({
|
||||||
String? idempotencyKey,
|
String? idempotencyKey,
|
||||||
Map<String, String>? metadata,
|
Map<String, String>? metadata,
|
||||||
|
String? intentRef,
|
||||||
|
List<String>? intentRefs,
|
||||||
}) async {
|
}) async {
|
||||||
if (!_organization.isOrganizationSet) {
|
if (!_organization.isOrganizationSet) {
|
||||||
throw StateError('Organization is not set');
|
throw StateError('Organization is not set');
|
||||||
@@ -53,6 +54,8 @@ class MultiPaymentProvider extends ChangeNotifier {
|
|||||||
quoteRef,
|
quoteRef,
|
||||||
idempotencyKey: idempotencyKey,
|
idempotencyKey: idempotencyKey,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
intentRef: intentRef,
|
||||||
|
intentRefs: intentRefs,
|
||||||
);
|
);
|
||||||
|
|
||||||
_setResource(
|
_setResource(
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import 'package:pshared/models/payment/quote/quotes.dart';
|
|||||||
import 'package:pshared/service/authorization/service.dart';
|
import 'package:pshared/service/authorization/service.dart';
|
||||||
import 'package:pshared/service/services.dart';
|
import 'package:pshared/service/services.dart';
|
||||||
|
|
||||||
|
|
||||||
class MultiplePaymentsService {
|
class MultiplePaymentsService {
|
||||||
static final _logger = Logger('service.payment.multiple');
|
static final _logger = Logger('service.payment.multiple');
|
||||||
static const String _objectType = Services.payments;
|
static const String _objectType = Services.payments;
|
||||||
@@ -38,6 +37,8 @@ class MultiplePaymentsService {
|
|||||||
String quoteRef, {
|
String quoteRef, {
|
||||||
String? idempotencyKey,
|
String? idempotencyKey,
|
||||||
Map<String, String>? metadata,
|
Map<String, String>? metadata,
|
||||||
|
String? intentRef,
|
||||||
|
List<String>? intentRefs,
|
||||||
}) async {
|
}) async {
|
||||||
_logger.fine(
|
_logger.fine(
|
||||||
'Executing multiple payments for quote $quoteRef in $organizationRef',
|
'Executing multiple payments for quote $quoteRef in $organizationRef',
|
||||||
@@ -46,6 +47,8 @@ class MultiplePaymentsService {
|
|||||||
idempotencyKey: idempotencyKey ?? const Uuid().v4(),
|
idempotencyKey: idempotencyKey ?? const Uuid().v4(),
|
||||||
quoteRef: quoteRef,
|
quoteRef: quoteRef,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
|
intentRef: intentRef,
|
||||||
|
intentRefs: intentRefs,
|
||||||
);
|
);
|
||||||
|
|
||||||
final response = await AuthorizationService.getPOSTResponse(
|
final response = await AuthorizationService.getPOSTResponse(
|
||||||
|
|||||||
@@ -114,6 +114,7 @@ void main() {
|
|||||||
'idempotencyKey': 'idem-1',
|
'idempotencyKey': 'idem-1',
|
||||||
'quote': {
|
'quote': {
|
||||||
'quoteRef': 'q-1',
|
'quoteRef': 'q-1',
|
||||||
|
'intentRef': 'intent-1',
|
||||||
'amounts': {
|
'amounts': {
|
||||||
'sourcePrincipal': {'amount': '10', 'currency': 'USDT'},
|
'sourcePrincipal': {'amount': '10', 'currency': 'USDT'},
|
||||||
'sourceDebitTotal': {'amount': '10.75', 'currency': 'USDT'},
|
'sourceDebitTotal': {'amount': '10.75', 'currency': 'USDT'},
|
||||||
@@ -148,6 +149,7 @@ void main() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(response.quote.fxQuote?.pricedAtUnixMs, equals(1771945907000));
|
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.amounts?.sourceDebitTotal?.amount, equals('10.75'));
|
||||||
expect(response.quote.fees?.lines?.length, equals(1));
|
expect(response.quote.fees?.lines?.length, equals(1));
|
||||||
});
|
});
|
||||||
@@ -174,16 +176,35 @@ void main() {
|
|||||||
final request = InitiatePaymentsRequest(
|
final request = InitiatePaymentsRequest(
|
||||||
idempotencyKey: 'idem-3',
|
idempotencyKey: 'idem-3',
|
||||||
quoteRef: 'q-2',
|
quoteRef: 'q-2',
|
||||||
|
intentRefs: const ['intent-a', 'intent-b'],
|
||||||
metadata: const {'client_payment_ref': 'cp-1'},
|
metadata: const {'client_payment_ref': 'cp-1'},
|
||||||
);
|
);
|
||||||
|
|
||||||
final json = request.toJson();
|
final json = request.toJson();
|
||||||
expect(json['idempotencyKey'], equals('idem-3'));
|
expect(json['idempotencyKey'], equals('idem-3'));
|
||||||
expect(json['quoteRef'], equals('q-2'));
|
expect(json['quoteRef'], equals('q-2'));
|
||||||
|
expect(json['intentRefs'], equals(const ['intent-a', 'intent-b']));
|
||||||
expect(
|
expect(
|
||||||
(json['metadata'] as Map<String, dynamic>)['client_payment_ref'],
|
(json['metadata'] as Map<String, dynamic>)['client_payment_ref'],
|
||||||
equals('cp-1'),
|
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);
|
||||||
|
},
|
||||||
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,6 +188,7 @@ class MultiplePayoutsProvider extends ChangeNotifier {
|
|||||||
try {
|
try {
|
||||||
_setState(MultiplePayoutsState.sending);
|
_setState(MultiplePayoutsState.sending);
|
||||||
_error = null;
|
_error = null;
|
||||||
|
final intentRefs = _quotedIntentRefs();
|
||||||
|
|
||||||
final result = await payment.pay(
|
final result = await payment.pay(
|
||||||
metadata: <String, String>{
|
metadata: <String, String>{
|
||||||
@@ -197,6 +198,7 @@ class MultiplePayoutsProvider extends ChangeNotifier {
|
|||||||
'upload_rows': _rows.length.toString(),
|
'upload_rows': _rows.length.toString(),
|
||||||
...?_uploadAmountMetadata(),
|
...?_uploadAmountMetadata(),
|
||||||
},
|
},
|
||||||
|
intentRefs: intentRefs.isEmpty ? null : intentRefs,
|
||||||
);
|
);
|
||||||
|
|
||||||
_sentCount = result.length;
|
_sentCount = result.length;
|
||||||
@@ -272,6 +274,20 @@ class MultiplePayoutsProvider extends ChangeNotifier {
|
|||||||
List<PaymentQuote> _quoteItems() =>
|
List<PaymentQuote> _quoteItems() =>
|
||||||
_quotation?.quotation?.items ?? const <PaymentQuote>[];
|
_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
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_quotation?.removeListener(_onQuotationChanged);
|
_quotation?.removeListener(_onQuotationChanged);
|
||||||
|
|||||||
Reference in New Issue
Block a user