removed intent_ref from frontend
This commit is contained in:
@@ -25,10 +25,9 @@ func (s *svc) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePa
|
|||||||
if req != nil && req.GetMeta() != nil {
|
if req != nil && req.GetMeta() != nil {
|
||||||
orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef())
|
orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef())
|
||||||
}
|
}
|
||||||
logger.Debug("Starting Execute payment",
|
logger.Debug("Starting payment execution",
|
||||||
zap.String("organization_ref", orgRef),
|
zap.String("organization_ref", orgRef),
|
||||||
zap.String("quotation_ref", strings.TrimSpace(req.GetQuotationRef())),
|
zap.String("quotation_ref", strings.TrimSpace(req.GetQuotationRef())),
|
||||||
zap.String("intent_ref", strings.TrimSpace(req.GetIntentRef())),
|
|
||||||
zap.Bool("has_client_payment_ref", strings.TrimSpace(req.GetClientPaymentRef()) != ""),
|
zap.Bool("has_client_payment_ref", strings.TrimSpace(req.GetClientPaymentRef()) != ""),
|
||||||
)
|
)
|
||||||
defer func(start time.Time) {
|
defer func(start time.Time) {
|
||||||
@@ -44,9 +43,13 @@ func (s *svc) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePa
|
|||||||
logger.Warn("Failed to execute payment", append(fields, zap.Error(err))...)
|
logger.Warn("Failed to execute payment", append(fields, zap.Error(err))...)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.Debug("Completed Execute payment", fields...)
|
logger.Debug("Completed payment execution", fields...)
|
||||||
}(time.Now())
|
}(time.Now())
|
||||||
|
|
||||||
|
if req != nil && strings.TrimSpace(req.GetIntentRef()) != "" {
|
||||||
|
return nil, merrors.InvalidArgument("intent_ref is no longer supported for ExecutePayment")
|
||||||
|
}
|
||||||
|
|
||||||
requestCtx, fingerprint, err := s.prepareExecute(req)
|
requestCtx, fingerprint, err := s.prepareExecute(req)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -104,7 +107,6 @@ func (s *svc) prepareExecute(req *orchestrationv2.ExecutePaymentRequest) (*reqva
|
|||||||
fingerprint, err := s.idempotency.Fingerprint(idem.FPInput{
|
fingerprint, err := s.idempotency.Fingerprint(idem.FPInput{
|
||||||
OrganizationRef: requestCtx.OrganizationRef,
|
OrganizationRef: requestCtx.OrganizationRef,
|
||||||
QuotationRef: requestCtx.QuotationRef,
|
QuotationRef: requestCtx.QuotationRef,
|
||||||
IntentRef: requestCtx.IntentRef,
|
|
||||||
ClientPaymentRef: requestCtx.ClientPaymentRef,
|
ClientPaymentRef: requestCtx.ClientPaymentRef,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -119,7 +121,6 @@ func mapExecuteReq(req *orchestrationv2.ExecutePaymentRequest) *reqval.Req {
|
|||||||
}
|
}
|
||||||
out := &reqval.Req{
|
out := &reqval.Req{
|
||||||
QuotationRef: req.GetQuotationRef(),
|
QuotationRef: req.GetQuotationRef(),
|
||||||
IntentRef: req.GetIntentRef(),
|
|
||||||
ClientPaymentRef: req.GetClientPaymentRef(),
|
ClientPaymentRef: req.GetClientPaymentRef(),
|
||||||
}
|
}
|
||||||
meta := req.GetMeta()
|
meta := req.GetMeta()
|
||||||
@@ -148,7 +149,6 @@ func (s *svc) tryReuse(ctx context.Context, requestCtx *reqval.Ctx, requestFinge
|
|||||||
existingFingerprint, err := s.idempotency.Fingerprint(idem.FPInput{
|
existingFingerprint, err := s.idempotency.Fingerprint(idem.FPInput{
|
||||||
OrganizationRef: requestCtx.OrganizationRef,
|
OrganizationRef: requestCtx.OrganizationRef,
|
||||||
QuotationRef: existing.QuotationRef,
|
QuotationRef: existing.QuotationRef,
|
||||||
IntentRef: existing.IntentSnapshot.Ref,
|
|
||||||
ClientPaymentRef: existing.ClientPaymentRef,
|
ClientPaymentRef: existing.ClientPaymentRef,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -202,7 +202,6 @@ func (s *svc) resolveAndPlan(ctx context.Context, requestCtx *reqval.Ctx) (*qsna
|
|||||||
resolved, err := s.quote.Resolve(ctx, s.quoteStore, qsnap.Input{
|
resolved, err := s.quote.Resolve(ctx, s.quoteStore, qsnap.Input{
|
||||||
OrganizationID: requestCtx.OrganizationID,
|
OrganizationID: requestCtx.OrganizationID,
|
||||||
QuotationRef: requestCtx.QuotationRef,
|
QuotationRef: requestCtx.QuotationRef,
|
||||||
IntentRef: requestCtx.IntentRef,
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, nil, remapResolveError(err)
|
return nil, nil, remapResolveError(err)
|
||||||
@@ -271,7 +270,7 @@ func remapIdempotencyError(err error) error {
|
|||||||
|
|
||||||
func remapResolveError(err error) error {
|
func remapResolveError(err error) error {
|
||||||
switch {
|
switch {
|
||||||
case errors.Is(err, qsnap.ErrIntentRefRequired), errors.Is(err, qsnap.ErrIntentRefNotFound):
|
case errors.Is(err, qsnap.ErrBatchQuoteUnsupported):
|
||||||
return merrors.InvalidArgument(err.Error())
|
return merrors.InvalidArgument(err.Error())
|
||||||
default:
|
default:
|
||||||
return err
|
return err
|
||||||
@@ -285,7 +284,6 @@ func mustFingerprint(idemSvc idem.Service, requestCtx *reqval.Ctx) string {
|
|||||||
value, err := idemSvc.Fingerprint(idem.FPInput{
|
value, err := idemSvc.Fingerprint(idem.FPInput{
|
||||||
OrganizationRef: requestCtx.OrganizationRef,
|
OrganizationRef: requestCtx.OrganizationRef,
|
||||||
QuotationRef: requestCtx.QuotationRef,
|
QuotationRef: requestCtx.QuotationRef,
|
||||||
IntentRef: requestCtx.IntentRef,
|
|
||||||
ClientPaymentRef: requestCtx.ClientPaymentRef,
|
ClientPaymentRef: requestCtx.ClientPaymentRef,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ func (s *svc) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.Exec
|
|||||||
if req != nil && req.GetMeta() != nil {
|
if req != nil && req.GetMeta() != nil {
|
||||||
orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef())
|
orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef())
|
||||||
}
|
}
|
||||||
logger.Debug("Starting ExecuteBatchPayment",
|
logger.Debug("Starting batch payment execution",
|
||||||
zap.String("organization_ref", orgRef),
|
zap.String("organization_ref", orgRef),
|
||||||
zap.String("quotation_ref", strings.TrimSpace(req.GetQuotationRef())),
|
zap.String("quotation_ref", strings.TrimSpace(req.GetQuotationRef())),
|
||||||
zap.Bool("has_client_payment_ref", strings.TrimSpace(req.GetClientPaymentRef()) != ""),
|
zap.Bool("has_client_payment_ref", strings.TrimSpace(req.GetClientPaymentRef()) != ""),
|
||||||
@@ -41,7 +41,7 @@ func (s *svc) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.Exec
|
|||||||
logger.Warn("Failed to execute batch payment", append(fields, zap.Error(err))...)
|
logger.Warn("Failed to execute batch payment", append(fields, zap.Error(err))...)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
logger.Debug("Completed ExecuteBatchPayment", fields...)
|
logger.Debug("Completed batch payment execution", fields...)
|
||||||
}(time.Now())
|
}(time.Now())
|
||||||
|
|
||||||
requestCtx, err := s.prepareBatchExecute(req)
|
requestCtx, err := s.prepareBatchExecute(req)
|
||||||
|
|||||||
@@ -41,7 +41,6 @@ func TestExecutePayment_EndToEndSyncSettled(t *testing.T) {
|
|||||||
Meta: testMeta(env.orgID, "idem-sync"),
|
Meta: testMeta(env.orgID, "idem-sync"),
|
||||||
QuotationRef: "quote-sync",
|
QuotationRef: "quote-sync",
|
||||||
ClientPaymentRef: "client-1",
|
ClientPaymentRef: "client-1",
|
||||||
IntentRef: "intent-sync",
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ExecutePayment returned error: %v", err)
|
t.Fatalf("ExecutePayment returned error: %v", err)
|
||||||
@@ -89,7 +88,6 @@ func TestExecutePayment_IdempotencyMismatch(t *testing.T) {
|
|||||||
Meta: testMeta(env.orgID, "idem-shared"),
|
Meta: testMeta(env.orgID, "idem-shared"),
|
||||||
QuotationRef: "quote-idem",
|
QuotationRef: "quote-idem",
|
||||||
ClientPaymentRef: "client-a",
|
ClientPaymentRef: "client-a",
|
||||||
IntentRef: "intent-idem",
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("first ExecutePayment returned error: %v", err)
|
t.Fatalf("first ExecutePayment returned error: %v", err)
|
||||||
@@ -99,14 +97,35 @@ func TestExecutePayment_IdempotencyMismatch(t *testing.T) {
|
|||||||
Meta: testMeta(env.orgID, "idem-shared"),
|
Meta: testMeta(env.orgID, "idem-shared"),
|
||||||
QuotationRef: "quote-idem",
|
QuotationRef: "quote-idem",
|
||||||
ClientPaymentRef: "client-b",
|
ClientPaymentRef: "client-b",
|
||||||
IntentRef: "intent-idem",
|
|
||||||
})
|
})
|
||||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||||
t.Fatalf("expected invalid argument for mismatch, got %v", err)
|
t.Fatalf("expected invalid argument for mismatch, got %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestExecutePayment_BatchQuoteRequiresIntentRef(t *testing.T) {
|
func TestExecutePayment_RejectsDeprecatedIntentRef(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(newExecutableQuote(env.orgID, "quote-sync", "intent-sync", buildLedgerRoute()))
|
||||||
|
|
||||||
|
_, err := env.svc.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{
|
||||||
|
Meta: testMeta(env.orgID, "idem-intent-ref"),
|
||||||
|
QuotationRef: "quote-sync",
|
||||||
|
ClientPaymentRef: "client-1",
|
||||||
|
IntentRef: "intent-sync",
|
||||||
|
})
|
||||||
|
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||||
|
t.Fatalf("expected invalid argument for deprecated intent_ref, got %v", err)
|
||||||
|
}
|
||||||
|
if got := err.Error(); !strings.Contains(got, "intent_ref is no longer supported for ExecutePayment") {
|
||||||
|
t.Fatalf("unexpected error message: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExecutePayment_BatchQuoteRequiresBatchExecution(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) {
|
||||||
step := req.StepExecution
|
step := req.StepExecution
|
||||||
step.State = agg.StepStateCompleted
|
step.State = agg.StepStateCompleted
|
||||||
@@ -120,9 +139,9 @@ func TestExecutePayment_BatchQuoteRequiresIntentRef(t *testing.T) {
|
|||||||
ClientPaymentRef: "client-batch",
|
ClientPaymentRef: "client-batch",
|
||||||
})
|
})
|
||||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||||
t.Fatalf("expected invalid argument for missing intent_ref, got %v", err)
|
t.Fatalf("expected invalid argument for unsupported batch quote, got %v", err)
|
||||||
}
|
}
|
||||||
if got := err.Error(); !strings.Contains(got, "intent_ref is required for batch quotation") {
|
if got := err.Error(); !strings.Contains(got, "batch quotation is not supported for ExecutePayment; use ExecuteBatchPayment") {
|
||||||
t.Fatalf("unexpected error message: %q", got)
|
t.Fatalf("unexpected error message: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -142,7 +161,6 @@ func TestExecutePayment_RetryThenSuccess(t *testing.T) {
|
|||||||
Meta: testMeta(env.orgID, "idem-retry"),
|
Meta: testMeta(env.orgID, "idem-retry"),
|
||||||
QuotationRef: "quote-retry",
|
QuotationRef: "quote-retry",
|
||||||
ClientPaymentRef: "client-retry",
|
ClientPaymentRef: "client-retry",
|
||||||
IntentRef: "intent-retry",
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ExecutePayment returned error: %v", err)
|
t.Fatalf("ExecutePayment returned error: %v", err)
|
||||||
@@ -186,7 +204,6 @@ func TestReconcileExternal_AdvancesAsyncPaymentToSettled(t *testing.T) {
|
|||||||
Meta: testMeta(env.orgID, "idem-async"),
|
Meta: testMeta(env.orgID, "idem-async"),
|
||||||
QuotationRef: "quote-async",
|
QuotationRef: "quote-async",
|
||||||
ClientPaymentRef: "client-async",
|
ClientPaymentRef: "client-async",
|
||||||
IntentRef: "intent-async",
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("ExecutePayment returned error: %v", err)
|
t.Fatalf("ExecutePayment returned error: %v", err)
|
||||||
|
|||||||
@@ -7,6 +7,5 @@ var (
|
|||||||
ErrQuoteExpired = errors.New("quotation_ref expired")
|
ErrQuoteExpired = errors.New("quotation_ref expired")
|
||||||
ErrQuoteNotExecutable = errors.New("quotation_ref is not executable")
|
ErrQuoteNotExecutable = errors.New("quotation_ref is not executable")
|
||||||
ErrQuoteShapeMismatch = errors.New("quotation payload shape mismatch")
|
ErrQuoteShapeMismatch = errors.New("quotation payload shape mismatch")
|
||||||
ErrIntentRefRequired = errors.New("intent_ref is required for batch quotation")
|
ErrBatchQuoteUnsupported = errors.New("batch quotation is not supported for ExecutePayment; use ExecuteBatchPayment")
|
||||||
ErrIntentRefNotFound = errors.New("intent_ref not found in quotation")
|
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -25,13 +25,11 @@ type Resolver interface {
|
|||||||
type Input struct {
|
type Input struct {
|
||||||
OrganizationID bson.ObjectID
|
OrganizationID bson.ObjectID
|
||||||
QuotationRef string
|
QuotationRef string
|
||||||
IntentRef string
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Output contains extracted canonical snapshots for execution.
|
// Output contains extracted canonical snapshots for execution.
|
||||||
type Output struct {
|
type Output struct {
|
||||||
QuotationRef string
|
QuotationRef string
|
||||||
IntentRef string
|
|
||||||
IntentSnapshot model.PaymentIntent
|
IntentSnapshot model.PaymentIntent
|
||||||
QuoteSnapshot *model.PaymentQuoteSnapshot
|
QuoteSnapshot *model.PaymentQuoteSnapshot
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -142,7 +142,6 @@ func TestResolve_ShapeMismatch(t *testing.T) {
|
|||||||
}, Input{
|
}, Input{
|
||||||
OrganizationID: bson.NewObjectID(),
|
OrganizationID: bson.NewObjectID(),
|
||||||
QuotationRef: "quote-ref",
|
QuotationRef: "quote-ref",
|
||||||
IntentRef: "intent-1",
|
|
||||||
})
|
})
|
||||||
if !errors.Is(err, ErrQuoteShapeMismatch) {
|
if !errors.Is(err, ErrQuoteShapeMismatch) {
|
||||||
t.Fatalf("expected ErrQuoteShapeMismatch, got %v", err)
|
t.Fatalf("expected ErrQuoteShapeMismatch, got %v", err)
|
||||||
|
|||||||
@@ -45,7 +45,6 @@ func TestResolve_SingleShapeOK(t *testing.T) {
|
|||||||
}, Input{
|
}, Input{
|
||||||
OrganizationID: orgID,
|
OrganizationID: orgID,
|
||||||
QuotationRef: "stored-quote-ref",
|
QuotationRef: "stored-quote-ref",
|
||||||
IntentRef: "intent-1",
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Resolve returned error: %v", err)
|
t.Fatalf("Resolve returned error: %v", err)
|
||||||
@@ -59,9 +58,6 @@ func TestResolve_SingleShapeOK(t *testing.T) {
|
|||||||
if got, want := out.IntentSnapshot.Ref, "intent-1"; got != want {
|
if got, want := out.IntentSnapshot.Ref, "intent-1"; got != want {
|
||||||
t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want)
|
t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want)
|
||||||
}
|
}
|
||||||
if got, want := out.IntentRef, "intent-1"; got != want {
|
|
||||||
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if out.QuoteSnapshot == nil {
|
if out.QuoteSnapshot == nil {
|
||||||
t.Fatal("expected quote snapshot")
|
t.Fatal("expected quote snapshot")
|
||||||
}
|
}
|
||||||
@@ -108,9 +104,6 @@ func TestResolve_ArrayShapeOK(t *testing.T) {
|
|||||||
if out == nil {
|
if out == nil {
|
||||||
t.Fatal("expected output")
|
t.Fatal("expected output")
|
||||||
}
|
}
|
||||||
if got, want := out.IntentRef, "intent-1"; got != want {
|
|
||||||
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := out.IntentSnapshot.Ref, "intent-1"; got != want {
|
if got, want := out.IntentSnapshot.Ref, "intent-1"; got != want {
|
||||||
t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want)
|
t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want)
|
||||||
}
|
}
|
||||||
@@ -122,7 +115,7 @@ func TestResolve_ArrayShapeOK(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestResolve_MultiShapeSelectByIntentRef(t *testing.T) {
|
func TestResolve_MultiShapeRejectedForSingleExecution(t *testing.T) {
|
||||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
||||||
orgID := bson.NewObjectID()
|
orgID := bson.NewObjectID()
|
||||||
|
|
||||||
@@ -153,91 +146,11 @@ func TestResolve_MultiShapeSelectByIntentRef(t *testing.T) {
|
|||||||
}, Input{
|
}, Input{
|
||||||
OrganizationID: orgID,
|
OrganizationID: orgID,
|
||||||
QuotationRef: "batch-quote-ref",
|
QuotationRef: "batch-quote-ref",
|
||||||
IntentRef: "intent-b",
|
|
||||||
})
|
})
|
||||||
if err != nil {
|
if out != nil {
|
||||||
t.Fatalf("Resolve returned error: %v", err)
|
t.Fatalf("expected nil output, got %#v", out)
|
||||||
}
|
}
|
||||||
if out == nil {
|
if !errors.Is(err, ErrBatchQuoteUnsupported) {
|
||||||
t.Fatal("expected output")
|
t.Fatalf("expected ErrBatchQuoteUnsupported, got %v", err)
|
||||||
}
|
|
||||||
if got, want := out.IntentSnapshot.Ref, "intent-b"; got != want {
|
|
||||||
t.Fatalf("intent.ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := out.IntentRef, "intent-b"; got != want {
|
|
||||||
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if out.QuoteSnapshot == nil || out.QuoteSnapshot.DebitAmount == nil {
|
|
||||||
t.Fatal("expected quote snapshot with debit amount")
|
|
||||||
}
|
|
||||||
if got, want := out.QuoteSnapshot.DebitAmount.Amount, "15"; got != want {
|
|
||||||
t.Fatalf("selected quote mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolve_MultiShapeRequiresIntentRef(t *testing.T) {
|
|
||||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
|
||||||
resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
|
||||||
|
|
||||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
|
||||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
|
||||||
return &model.PaymentQuoteRecord{
|
|
||||||
QuoteRef: "quote-ref",
|
|
||||||
RequestShape: model.QuoteRequestShapeBatch,
|
|
||||||
Items: []*model.PaymentQuoteItemV2{
|
|
||||||
{
|
|
||||||
Intent: &model.PaymentIntent{Ref: "intent-1", Kind: model.PaymentKindPayout},
|
|
||||||
Quote: &model.PaymentQuoteSnapshot{},
|
|
||||||
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Intent: &model.PaymentIntent{Ref: "intent-2", Kind: model.PaymentKindPayout},
|
|
||||||
Quote: &model.PaymentQuoteSnapshot{},
|
|
||||||
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ExpiresAt: now.Add(time.Minute),
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}, Input{
|
|
||||||
OrganizationID: bson.NewObjectID(),
|
|
||||||
QuotationRef: "quote-ref",
|
|
||||||
})
|
|
||||||
if !errors.Is(err, ErrIntentRefRequired) {
|
|
||||||
t.Fatalf("expected ErrIntentRefRequired, got %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestResolve_MultiShapeIntentRefNotFound(t *testing.T) {
|
|
||||||
now := time.Date(2026, time.January, 2, 3, 4, 5, 0, time.UTC)
|
|
||||||
resolver := New(Dependencies{Logger: zap.NewNop(), Now: func() time.Time { return now }})
|
|
||||||
|
|
||||||
_, err := resolver.Resolve(context.Background(), &fakeStore{
|
|
||||||
getByRefFn: func(context.Context, bson.ObjectID, string) (*model.PaymentQuoteRecord, error) {
|
|
||||||
return &model.PaymentQuoteRecord{
|
|
||||||
QuoteRef: "quote-ref",
|
|
||||||
RequestShape: model.QuoteRequestShapeBatch,
|
|
||||||
Items: []*model.PaymentQuoteItemV2{
|
|
||||||
{
|
|
||||||
Intent: &model.PaymentIntent{Ref: "intent-1", Kind: model.PaymentKindPayout},
|
|
||||||
Quote: &model.PaymentQuoteSnapshot{},
|
|
||||||
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
Intent: &model.PaymentIntent{Ref: "intent-2", Kind: model.PaymentKindPayout},
|
|
||||||
Quote: &model.PaymentQuoteSnapshot{},
|
|
||||||
Status: &model.QuoteStatusV2{State: model.QuoteStateExecutable},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ExpiresAt: now.Add(time.Minute),
|
|
||||||
}, nil
|
|
||||||
},
|
|
||||||
}, Input{
|
|
||||||
OrganizationID: bson.NewObjectID(),
|
|
||||||
QuotationRef: "quote-ref",
|
|
||||||
IntentRef: "intent-3",
|
|
||||||
})
|
|
||||||
if !errors.Is(err, ErrIntentRefNotFound) {
|
|
||||||
t.Fatalf("expected ErrIntentRefNotFound, got %v", err)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,14 +35,12 @@ func (s *svc) Resolve(
|
|||||||
logger.Debug("Starting Resolve",
|
logger.Debug("Starting Resolve",
|
||||||
zap.String("organization_ref", in.OrganizationID.Hex()),
|
zap.String("organization_ref", in.OrganizationID.Hex()),
|
||||||
zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)),
|
zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)),
|
||||||
zap.String("intent_ref", strings.TrimSpace(in.IntentRef)),
|
|
||||||
)
|
)
|
||||||
defer func(start time.Time) {
|
defer func(start time.Time) {
|
||||||
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())}
|
||||||
if out != nil {
|
if out != nil {
|
||||||
fields = append(fields,
|
fields = append(fields,
|
||||||
zap.String("quotation_ref", strings.TrimSpace(out.QuotationRef)),
|
zap.String("quotation_ref", strings.TrimSpace(out.QuotationRef)),
|
||||||
zap.String("intent_ref", strings.TrimSpace(out.IntentRef)),
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -63,7 +61,6 @@ func (s *svc) Resolve(
|
|||||||
if quoteRef == "" {
|
if quoteRef == "" {
|
||||||
return nil, merrors.InvalidArgument("quotation_ref is required")
|
return nil, merrors.InvalidArgument("quotation_ref is required")
|
||||||
}
|
}
|
||||||
intentRef := strings.TrimSpace(in.IntentRef)
|
|
||||||
|
|
||||||
record, err := store.GetByRef(ctx, in.OrganizationID, quoteRef)
|
record, err := store.GetByRef(ctx, in.OrganizationID, quoteRef)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -76,7 +73,7 @@ func (s *svc) Resolve(
|
|||||||
return nil, ErrQuoteNotFound
|
return nil, ErrQuoteNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
item, err := resolveRecordItem(record, intentRef)
|
item, err := resolveRecordItem(record)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -95,7 +92,6 @@ func (s *svc) Resolve(
|
|||||||
|
|
||||||
out = &Output{
|
out = &Output{
|
||||||
QuotationRef: outputRef,
|
QuotationRef: outputRef,
|
||||||
IntentRef: firstNonEmpty(strings.TrimSpace(item.Intent.Ref), intentRef),
|
|
||||||
IntentSnapshot: item.Intent,
|
IntentSnapshot: item.Intent,
|
||||||
QuoteSnapshot: item.Quote,
|
QuoteSnapshot: item.Quote,
|
||||||
}
|
}
|
||||||
@@ -165,7 +161,7 @@ func (s *svc) ResolveAll(
|
|||||||
if err := ensureExecutable(record, item.Status, s.now().UTC()); err != nil {
|
if err := ensureExecutable(record, item.Status, s.now().UTC()); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
resolved, err := resolveItem(item, "")
|
resolved, err := resolveItem(item)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, xerr.Wrapf(err, "items[%d]", i)
|
return nil, xerr.Wrapf(err, "items[%d]", i)
|
||||||
}
|
}
|
||||||
@@ -225,7 +221,7 @@ func ensureExecutable(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveRecordItem(record *model.PaymentQuoteRecord, intentRef string) (*resolvedQuoteItem, error) {
|
func resolveRecordItem(record *model.PaymentQuoteRecord) (*resolvedQuoteItem, error) {
|
||||||
if record == nil {
|
if record == nil {
|
||||||
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "record is nil")
|
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "record is nil")
|
||||||
}
|
}
|
||||||
@@ -238,42 +234,18 @@ func resolveRecordItem(record *model.PaymentQuoteRecord, intentRef string) (*res
|
|||||||
if len(record.Items) != 1 {
|
if len(record.Items) != 1 {
|
||||||
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "single shape requires exactly one item")
|
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "single shape requires exactly one item")
|
||||||
}
|
}
|
||||||
return resolveItem(record.Items[0], intentRef)
|
return resolveItem(record.Items[0])
|
||||||
case model.QuoteRequestShapeBatch:
|
case model.QuoteRequestShapeBatch:
|
||||||
index, err := resolveBatchItemIndex(record.Items, intentRef)
|
if len(record.Items) != 1 {
|
||||||
if err != nil {
|
return nil, ErrBatchQuoteUnsupported
|
||||||
return nil, err
|
|
||||||
}
|
}
|
||||||
return resolveItem(record.Items[index], intentRef)
|
return resolveItem(record.Items[0])
|
||||||
default:
|
default:
|
||||||
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "request shape is invalid")
|
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "request shape is invalid")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func resolveBatchItemIndex(items []*model.PaymentQuoteItemV2, intentRef string) (int, error) {
|
func resolveItem(item *model.PaymentQuoteItemV2) (*resolvedQuoteItem, error) {
|
||||||
if len(items) == 1 {
|
|
||||||
if intentRef == "" {
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
item := items[0]
|
|
||||||
if item == nil || item.Intent == nil || strings.TrimSpace(item.Intent.Ref) != intentRef {
|
|
||||||
return -1, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef)
|
|
||||||
}
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
if intentRef == "" {
|
|
||||||
return -1, ErrIntentRefRequired
|
|
||||||
}
|
|
||||||
|
|
||||||
index, found := findItemIndex(items, intentRef)
|
|
||||||
if !found {
|
|
||||||
return -1, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef)
|
|
||||||
}
|
|
||||||
return index, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func resolveItem(item *model.PaymentQuoteItemV2, intentRef string) (*resolvedQuoteItem, error) {
|
|
||||||
if item == nil {
|
if item == nil {
|
||||||
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "item is nil")
|
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "item is nil")
|
||||||
}
|
}
|
||||||
@@ -286,9 +258,6 @@ func resolveItem(item *model.PaymentQuoteItemV2, intentRef string) (*resolvedQuo
|
|||||||
if item.Status == nil {
|
if item.Status == nil {
|
||||||
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "status is nil")
|
return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "status is nil")
|
||||||
}
|
}
|
||||||
if intentRef != "" && strings.TrimSpace(item.Intent.Ref) != intentRef {
|
|
||||||
return nil, xerr.Wrapf(ErrIntentRefNotFound, "%s", intentRef)
|
|
||||||
}
|
|
||||||
|
|
||||||
intentSnapshot, err := cloneIntentSnapshot(*item.Intent)
|
intentSnapshot, err := cloneIntentSnapshot(*item.Intent)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -310,32 +279,6 @@ func resolveItem(item *model.PaymentQuoteItemV2, intentRef string) (*resolvedQuo
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func findItemIndex(items []*model.PaymentQuoteItemV2, targetRef string) (int, bool) {
|
|
||||||
target := strings.TrimSpace(targetRef)
|
|
||||||
if target == "" {
|
|
||||||
return -1, false
|
|
||||||
}
|
|
||||||
for idx := range items {
|
|
||||||
item := items[idx]
|
|
||||||
if item == nil || item.Intent == nil {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.TrimSpace(item.Intent.Ref) == target {
|
|
||||||
return idx, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return -1, false
|
|
||||||
}
|
|
||||||
|
|
||||||
func firstNonEmpty(values ...string) string {
|
|
||||||
for _, value := range values {
|
|
||||||
if trimmed := strings.TrimSpace(value); trimmed != "" {
|
|
||||||
return trimmed
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneIntentSnapshot(src model.PaymentIntent) (model.PaymentIntent, error) {
|
func cloneIntentSnapshot(src model.PaymentIntent) (model.PaymentIntent, error) {
|
||||||
var dst model.PaymentIntent
|
var dst model.PaymentIntent
|
||||||
if err := bsonClone(src, &dst); err != nil {
|
if err := bsonClone(src, &dst); err != nil {
|
||||||
|
|||||||
@@ -16,7 +16,6 @@ type Validator interface {
|
|||||||
type Req struct {
|
type Req struct {
|
||||||
Meta *Meta
|
Meta *Meta
|
||||||
QuotationRef string
|
QuotationRef string
|
||||||
IntentRef string
|
|
||||||
ClientPaymentRef string
|
ClientPaymentRef string
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -37,7 +36,6 @@ type Ctx struct {
|
|||||||
OrganizationID bson.ObjectID
|
OrganizationID bson.ObjectID
|
||||||
IdempotencyKey string
|
IdempotencyKey string
|
||||||
QuotationRef string
|
QuotationRef string
|
||||||
IntentRef string
|
|
||||||
ClientPaymentRef string
|
ClientPaymentRef string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ import (
|
|||||||
const (
|
const (
|
||||||
maxIdempotencyKeyLen = 256
|
maxIdempotencyKeyLen = 256
|
||||||
maxQuotationRefLen = 128
|
maxQuotationRefLen = 128
|
||||||
maxIntentRefLen = 128
|
|
||||||
maxClientRefLen = 128
|
maxClientRefLen = 128
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -33,7 +32,6 @@ func (s *svc) Validate(req *Req) (out *Ctx, err error) {
|
|||||||
logger.Debug("Starting Validate",
|
logger.Debug("Starting Validate",
|
||||||
zap.String("organization_ref", orgRefIn),
|
zap.String("organization_ref", orgRefIn),
|
||||||
zap.String("quotation_ref", strings.TrimSpace(valueOrEmpty(req, func(v *Req) string { return v.QuotationRef }))),
|
zap.String("quotation_ref", strings.TrimSpace(valueOrEmpty(req, func(v *Req) string { return v.QuotationRef }))),
|
||||||
zap.String("intent_ref", strings.TrimSpace(valueOrEmpty(req, func(v *Req) string { return v.IntentRef }))),
|
|
||||||
zap.Bool("has_idempotency_key", strings.TrimSpace(traceKey(req)) != ""),
|
zap.Bool("has_idempotency_key", strings.TrimSpace(traceKey(req)) != ""),
|
||||||
)
|
)
|
||||||
defer func(start time.Time) {
|
defer func(start time.Time) {
|
||||||
@@ -42,7 +40,6 @@ func (s *svc) Validate(req *Req) (out *Ctx, err error) {
|
|||||||
fields = append(fields,
|
fields = append(fields,
|
||||||
zap.String("organization_ref", out.OrganizationRef),
|
zap.String("organization_ref", out.OrganizationRef),
|
||||||
zap.String("quotation_ref", out.QuotationRef),
|
zap.String("quotation_ref", out.QuotationRef),
|
||||||
zap.String("intent_ref", out.IntentRef),
|
|
||||||
zap.Bool("has_client_payment_ref", out.ClientPaymentRef != ""),
|
zap.Bool("has_client_payment_ref", out.ClientPaymentRef != ""),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -83,11 +80,6 @@ func (s *svc) Validate(req *Req) (out *Ctx, err error) {
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
intentRef, err := validateRefToken("intent_ref", req.IntentRef, maxIntentRefLen, false)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
clientPaymentRef, err := validateRefToken("client_payment_ref", req.ClientPaymentRef, maxClientRefLen, false)
|
clientPaymentRef, err := validateRefToken("client_payment_ref", req.ClientPaymentRef, maxClientRefLen, false)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -98,7 +90,6 @@ func (s *svc) Validate(req *Req) (out *Ctx, err error) {
|
|||||||
OrganizationID: orgID,
|
OrganizationID: orgID,
|
||||||
IdempotencyKey: idempotencyKey,
|
IdempotencyKey: idempotencyKey,
|
||||||
QuotationRef: quotationRef,
|
QuotationRef: quotationRef,
|
||||||
IntentRef: intentRef,
|
|
||||||
ClientPaymentRef: clientPaymentRef,
|
ClientPaymentRef: clientPaymentRef,
|
||||||
}
|
}
|
||||||
return out, nil
|
return out, nil
|
||||||
|
|||||||
@@ -19,7 +19,6 @@ func TestValidate_OK(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
QuotationRef: " quote-ref-1 ",
|
QuotationRef: " quote-ref-1 ",
|
||||||
IntentRef: " intent-ref-1 ",
|
|
||||||
ClientPaymentRef: " client.ref-1 ",
|
ClientPaymentRef: " client.ref-1 ",
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -40,9 +39,6 @@ func TestValidate_OK(t *testing.T) {
|
|||||||
if got, want := ctx.QuotationRef, "quote-ref-1"; got != want {
|
if got, want := ctx.QuotationRef, "quote-ref-1"; got != want {
|
||||||
t.Fatalf("quotation_ref mismatch: got=%q want=%q", got, want)
|
t.Fatalf("quotation_ref mismatch: got=%q want=%q", got, want)
|
||||||
}
|
}
|
||||||
if got, want := ctx.IntentRef, "intent-ref-1"; got != want {
|
|
||||||
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
|
|
||||||
}
|
|
||||||
if got, want := ctx.ClientPaymentRef, "client.ref-1"; got != want {
|
if got, want := ctx.ClientPaymentRef, "client.ref-1"; got != want {
|
||||||
t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want)
|
t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want)
|
||||||
}
|
}
|
||||||
@@ -70,16 +66,12 @@ func TestValidate_ClientPaymentRefOptional(t *testing.T) {
|
|||||||
if ctx.ClientPaymentRef != "" {
|
if ctx.ClientPaymentRef != "" {
|
||||||
t.Fatalf("expected empty client_payment_ref, got %q", ctx.ClientPaymentRef)
|
t.Fatalf("expected empty client_payment_ref, got %q", ctx.ClientPaymentRef)
|
||||||
}
|
}
|
||||||
if ctx.IntentRef != "" {
|
|
||||||
t.Fatalf("expected empty intent_ref, got %q", ctx.IntentRef)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidate_Errors(t *testing.T) {
|
func TestValidate_Errors(t *testing.T) {
|
||||||
orgID := bson.NewObjectID().Hex()
|
orgID := bson.NewObjectID().Hex()
|
||||||
tooLongIdem := "x" + strings.Repeat("a", maxIdempotencyKeyLen)
|
tooLongIdem := "x" + strings.Repeat("a", maxIdempotencyKeyLen)
|
||||||
tooLongQuote := "q" + strings.Repeat("a", maxQuotationRefLen)
|
tooLongQuote := "q" + strings.Repeat("a", maxQuotationRefLen)
|
||||||
tooLongIntent := "i" + strings.Repeat("a", maxIntentRefLen)
|
|
||||||
tooLongClient := "c" + strings.Repeat("a", maxClientRefLen)
|
tooLongClient := "c" + strings.Repeat("a", maxClientRefLen)
|
||||||
|
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
@@ -193,28 +185,6 @@ func TestValidate_Errors(t *testing.T) {
|
|||||||
ClientPaymentRef: tooLongClient,
|
ClientPaymentRef: tooLongClient,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "too long intent ref",
|
|
||||||
req: &Req{
|
|
||||||
Meta: &Meta{
|
|
||||||
OrganizationRef: orgID,
|
|
||||||
Trace: &Trace{IdempotencyKey: "idem-1"},
|
|
||||||
},
|
|
||||||
QuotationRef: "quote-1",
|
|
||||||
IntentRef: tooLongIntent,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "bad intent ref shape",
|
|
||||||
req: &Req{
|
|
||||||
Meta: &Meta{
|
|
||||||
OrganizationRef: orgID,
|
|
||||||
Trace: &Trace{IdempotencyKey: "idem-1"},
|
|
||||||
},
|
|
||||||
QuotationRef: "quote-1",
|
|
||||||
IntentRef: "intent ref",
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "bad client payment ref shape",
|
name: "bad client payment ref shape",
|
||||||
req: &Req{
|
req: &Req{
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import (
|
|||||||
|
|
||||||
func TestV2GRPCServerExecutePayment_MapsInvalidArgument(t *testing.T) {
|
func TestV2GRPCServerExecutePayment_MapsInvalidArgument(t *testing.T) {
|
||||||
srv := newV2GRPCServer(fakeV2Service{
|
srv := newV2GRPCServer(fakeV2Service{
|
||||||
executeErr: merrors.InvalidArgument("intent_ref is required for batch quotation"),
|
executeErr: merrors.InvalidArgument("batch quotation is not supported for ExecutePayment; use ExecuteBatchPayment"),
|
||||||
}, zap.NewNop())
|
}, zap.NewNop())
|
||||||
|
|
||||||
_, err := srv.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{})
|
_, err := srv.ExecutePayment(context.Background(), &orchestrationv2.ExecutePaymentRequest{})
|
||||||
@@ -25,7 +25,7 @@ func TestV2GRPCServerExecutePayment_MapsInvalidArgument(t *testing.T) {
|
|||||||
if got, want := status.Code(err), codes.InvalidArgument; got != want {
|
if got, want := status.Code(err), codes.InvalidArgument; got != want {
|
||||||
t.Fatalf("unexpected grpc status code: got=%s want=%s", got, want)
|
t.Fatalf("unexpected grpc status code: got=%s want=%s", got, want)
|
||||||
}
|
}
|
||||||
if got := status.Convert(err).Message(); !strings.Contains(got, "intent_ref is required for batch quotation") {
|
if got := status.Convert(err).Message(); !strings.Contains(got, "batch quotation is not supported for ExecutePayment; use ExecuteBatchPayment") {
|
||||||
t.Fatalf("unexpected grpc status message: %q", got)
|
t.Fatalf("unexpected grpc status message: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -108,8 +108,6 @@ 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 {
|
||||||
@@ -120,35 +118,9 @@ func (r *InitiatePayments) Validate() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
r.QuoteRef = strings.TrimSpace(r.QuoteRef)
|
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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,29 +28,11 @@ func TestValidateQuoteIdempotency(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInitiatePaymentsValidateIntentSelectors(t *testing.T) {
|
func TestInitiatePaymentsValidate(t *testing.T) {
|
||||||
t.Run("accepts explicit intentRef", func(t *testing.T) {
|
t.Run("accepts quoteRef", 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{
|
req := &InitiatePayments{
|
||||||
PaymentBase: PaymentBase{IdempotencyKey: "idem-1"},
|
PaymentBase: PaymentBase{IdempotencyKey: "idem-1"},
|
||||||
QuoteRef: " quote-1 ",
|
QuoteRef: " quote-1 ",
|
||||||
IntentRefs: []string{" intent-a ", "intent-b"},
|
|
||||||
}
|
}
|
||||||
if err := req.Validate(); err != nil {
|
if err := req.Validate(); err != nil {
|
||||||
t.Fatalf("expected no error, got %v", err)
|
t.Fatalf("expected no error, got %v", err)
|
||||||
@@ -58,66 +40,14 @@ func TestInitiatePaymentsValidateIntentSelectors(t *testing.T) {
|
|||||||
if got, want := req.QuoteRef, "quote-1"; got != want {
|
if got, want := req.QuoteRef, "quote-1"; got != want {
|
||||||
t.Fatalf("quoteRef mismatch: got=%q want=%q", 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) {
|
t.Run("rejects missing quoteRef", func(t *testing.T) {
|
||||||
req := &InitiatePayments{
|
req := &InitiatePayments{
|
||||||
PaymentBase: PaymentBase{IdempotencyKey: "idem-1"},
|
PaymentBase: PaymentBase{IdempotencyKey: "idem-1"},
|
||||||
QuoteRef: "quote-1",
|
|
||||||
IntentRef: "intent-a",
|
|
||||||
IntentRefs: []string{"intent-b"},
|
|
||||||
}
|
}
|
||||||
if err := req.Validate(); err == nil {
|
if err := req.Validate(); err == nil {
|
||||||
t.Fatal("expected error")
|
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)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,7 +61,9 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to
|
|||||||
}
|
}
|
||||||
|
|
||||||
quotationRef := strings.TrimSpace(payload.QuoteRef)
|
quotationRef := strings.TrimSpace(payload.QuoteRef)
|
||||||
intentRef := metadataValue(payload.Metadata, "intent_ref")
|
if metadataValue(payload.Metadata, "intent_ref") != "" {
|
||||||
|
return response.BadPayload(a.logger, a.Name(), merrors.InvalidArgument("metadata.intent_ref is no longer supported", "metadata.intent_ref"))
|
||||||
|
}
|
||||||
if payload.Intent != nil {
|
if payload.Intent != nil {
|
||||||
applyCustomerIP(payload.Intent, r.RemoteAddr)
|
applyCustomerIP(payload.Intent, r.RemoteAddr)
|
||||||
intent, err := mapQuoteIntent(payload.Intent)
|
intent, err := mapQuoteIntent(payload.Intent)
|
||||||
@@ -82,15 +84,11 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to
|
|||||||
if quotationRef == "" {
|
if quotationRef == "" {
|
||||||
return response.Auto(a.logger, a.Name(), merrors.DataConflict("quotation service returned empty quote_ref"))
|
return response.Auto(a.logger, a.Name(), merrors.DataConflict("quotation service returned empty quote_ref"))
|
||||||
}
|
}
|
||||||
if derived := strings.TrimSpace(quoteResp.GetQuote().GetIntentRef()); derived != "" {
|
|
||||||
intentRef = derived
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
req := &orchestrationv2.ExecutePaymentRequest{
|
req := &orchestrationv2.ExecutePaymentRequest{
|
||||||
Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey),
|
Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey),
|
||||||
QuotationRef: quotationRef,
|
QuotationRef: quotationRef,
|
||||||
IntentRef: intentRef,
|
|
||||||
ClientPaymentRef: metadataValue(payload.Metadata, "client_payment_ref"),
|
ClientPaymentRef: metadataValue(payload.Metadata, "client_payment_ref"),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
68
api/server/internal/server/paymentapiimp/pay_test.go
Normal file
68
api/server/internal/server/paymentapiimp/pay_test.go
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
package paymentapiimp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/go-chi/chi/v5"
|
||||||
|
"github.com/tech/sendico/pkg/model"
|
||||||
|
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||||
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestInitiateByQuote_DoesNotUseIntentRef(t *testing.T) {
|
||||||
|
orgRef := bson.NewObjectID()
|
||||||
|
exec := &fakeExecutionClientForBatch{}
|
||||||
|
api := newBatchAPI(exec)
|
||||||
|
|
||||||
|
body := `{"idempotencyKey":"idem-by-quote","quoteRef":"quote-1","metadata":{"client_payment_ref":"client-ref-1"}}`
|
||||||
|
rr := invokeInitiateByQuote(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(), ""; got != want {
|
||||||
|
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
if got, want := exec.executeReqs[0].GetClientPaymentRef(), "client-ref-1"; got != want {
|
||||||
|
t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInitiateByQuote_RejectsMetadataIntentRef(t *testing.T) {
|
||||||
|
orgRef := bson.NewObjectID()
|
||||||
|
exec := &fakeExecutionClientForBatch{}
|
||||||
|
api := newBatchAPI(exec)
|
||||||
|
|
||||||
|
body := `{"idempotencyKey":"idem-by-quote","quoteRef":"quote-1","metadata":{"intent_ref":"legacy-intent"}}`
|
||||||
|
rr := invokeInitiateByQuote(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 invokeInitiateByQuote(t *testing.T, api *PaymentAPI, orgRef bson.ObjectID, body string) *httptest.ResponseRecorder {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
req := httptest.NewRequest(http.MethodPost, "/by-quote", 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.initiateByQuote(req, &model.Account{}, &sresponse.TokenData{
|
||||||
|
Token: "token",
|
||||||
|
Expiration: time.Now().UTC().Add(time.Hour),
|
||||||
|
})
|
||||||
|
handler.ServeHTTP(rr, req)
|
||||||
|
return rr
|
||||||
|
}
|
||||||
@@ -39,100 +39,39 @@ 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")
|
clientPaymentRef := metadataValue(payload.Metadata, "client_payment_ref")
|
||||||
baseIdempotencyKey := strings.TrimSpace(payload.IdempotencyKey)
|
idempotencyKey := strings.TrimSpace(payload.IdempotencyKey)
|
||||||
quotationRef := strings.TrimSpace(payload.QuoteRef)
|
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
|
|
||||||
}
|
|
||||||
|
|
||||||
executeBatch := func(idempotencyKey string) ([]*orchestrationv2.Payment, error) {
|
|
||||||
req := &orchestrationv2.ExecuteBatchPaymentRequest{
|
req := &orchestrationv2.ExecuteBatchPaymentRequest{
|
||||||
Meta: requestMeta(orgRef.Hex(), idempotencyKey),
|
Meta: requestMeta(orgRef.Hex(), idempotencyKey),
|
||||||
QuotationRef: quotationRef,
|
QuotationRef: quotationRef,
|
||||||
ClientPaymentRef: clientPaymentRef,
|
ClientPaymentRef: clientPaymentRef,
|
||||||
}
|
}
|
||||||
resp, executeErr := a.execution.ExecuteBatchPayment(ctx, req)
|
resp, err := a.execution.ExecuteBatchPayment(ctx, req)
|
||||||
if executeErr != nil {
|
|
||||||
return nil, executeErr
|
|
||||||
}
|
|
||||||
if resp == nil {
|
|
||||||
return nil, nil
|
|
||||||
}
|
|
||||||
return resp.GetPayments(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
payments := make([]*orchestrationv2.Payment, 0, max(1, len(intentSelectors)))
|
|
||||||
if len(payload.IntentRefs) > 0 {
|
|
||||||
executed, executeErr := executeBatch(baseIdempotencyKey)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
payments = append(payments, executed...)
|
|
||||||
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 = append(payments, payment)
|
payments := make([]*orchestrationv2.Payment, 0)
|
||||||
|
if resp != nil {
|
||||||
|
payments = append(payments, resp.GetPayments()...)
|
||||||
}
|
}
|
||||||
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 decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments, error) {
|
func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments, error) {
|
||||||
defer r.Body.Close()
|
defer r.Body.Close()
|
||||||
|
|
||||||
payload := &srequest.InitiatePayments{}
|
payload := &srequest.InitiatePayments{}
|
||||||
if err := json.NewDecoder(r.Body).Decode(payload); err != nil {
|
decoder := json.NewDecoder(r.Body)
|
||||||
|
decoder.DisallowUnknownFields()
|
||||||
|
if err := decoder.Decode(payload); err != nil {
|
||||||
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
|
return nil, merrors.InvalidArgument("invalid payload: " + err.Error())
|
||||||
}
|
}
|
||||||
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
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ package paymentapiimp
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"context"
|
"context"
|
||||||
"errors"
|
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
@@ -11,24 +10,22 @@ import (
|
|||||||
|
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/tech/sendico/pkg/auth"
|
"github.com/tech/sendico/pkg/auth"
|
||||||
"github.com/tech/sendico/pkg/merrors"
|
|
||||||
"github.com/tech/sendico/pkg/mlogger"
|
"github.com/tech/sendico/pkg/mlogger"
|
||||||
"github.com/tech/sendico/pkg/model"
|
"github.com/tech/sendico/pkg/model"
|
||||||
"github.com/tech/sendico/pkg/mservice"
|
"github.com/tech/sendico/pkg/mservice"
|
||||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
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"
|
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||||
"go.mongodb.org/mongo-driver/v2/bson"
|
"go.mongodb.org/mongo-driver/v2/bson"
|
||||||
"go.uber.org/zap"
|
"go.uber.org/zap"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestInitiatePaymentsByQuote_PassesIntentRefsInSingleExecuteCall(t *testing.T) {
|
func TestInitiatePaymentsByQuote_ExecutesBatchPayment(t *testing.T) {
|
||||||
orgRef := bson.NewObjectID()
|
orgRef := bson.NewObjectID()
|
||||||
exec := &fakeExecutionClientForBatch{}
|
exec := &fakeExecutionClientForBatch{}
|
||||||
api := newBatchAPI(exec)
|
api := newBatchAPI(exec)
|
||||||
|
|
||||||
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","intentRefs":["intent-a","intent-b"]}`
|
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1"}`
|
||||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
||||||
if got, want := rr.Code, http.StatusOK; got != want {
|
if got, want := rr.Code, http.StatusOK; got != want {
|
||||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||||
@@ -48,53 +45,34 @@ func TestInitiatePaymentsByQuote_PassesIntentRefsInSingleExecuteCall(t *testing.
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInitiatePaymentsByQuote_UsesExplicitIntentRef(t *testing.T) {
|
func TestInitiatePaymentsByQuote_ForwardsClientPaymentRef(t *testing.T) {
|
||||||
orgRef := bson.NewObjectID()
|
orgRef := bson.NewObjectID()
|
||||||
exec := &fakeExecutionClientForBatch{}
|
exec := &fakeExecutionClientForBatch{}
|
||||||
api := newBatchAPI(exec)
|
api := newBatchAPI(exec)
|
||||||
|
|
||||||
body := `{"idempotencyKey":"idem-single","quoteRef":"quote-1","intentRef":"intent-x"}`
|
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","metadata":{"client_payment_ref":"client-ref-1"}}`
|
||||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
||||||
if got, want := rr.Code, http.StatusOK; got != want {
|
if got, want := rr.Code, http.StatusOK; got != want {
|
||||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||||
}
|
}
|
||||||
|
|
||||||
if got, want := len(exec.executeReqs), 1; got != want {
|
if got, want := len(exec.executeBatchReqs), 1; got != want {
|
||||||
t.Fatalf("execute calls mismatch: got=%d want=%d", got, want)
|
t.Fatalf("execute batch calls mismatch: got=%d want=%d", got, want)
|
||||||
}
|
}
|
||||||
if got, want := exec.executeReqs[0].GetIntentRef(), "intent-x"; got != want {
|
if got, want := exec.executeBatchReqs[0].GetClientPaymentRef(), "client-ref-1"; got != want {
|
||||||
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
|
t.Fatalf("client_payment_ref mismatch: got=%q want=%q", got, want)
|
||||||
}
|
}
|
||||||
if got, want := exec.executeReqs[0].GetMeta().GetTrace().GetIdempotencyKey(), "idem-single"; got != want {
|
if got := len(exec.executeReqs); got != 0 {
|
||||||
t.Fatalf("idempotency mismatch: got=%q want=%q", got, want)
|
t.Fatalf("expected no execute calls, got=%d", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInitiatePaymentsByQuote_UsesLegacyMetadataIntentRefFallback(t *testing.T) {
|
func TestInitiatePaymentsByQuote_RejectsDeprecatedIntentRefField(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()
|
orgRef := bson.NewObjectID()
|
||||||
exec := &fakeExecutionClientForBatch{}
|
exec := &fakeExecutionClientForBatch{}
|
||||||
api := newBatchAPI(exec)
|
api := newBatchAPI(exec)
|
||||||
|
|
||||||
body := `{"idempotencyKey":"idem-legacy","quoteRef":"quote-1","metadata":{"intent_ref":"intent-legacy"}}`
|
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","intentRef":"intent-legacy"}`
|
||||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
||||||
if got, want := rr.Code, http.StatusBadRequest; got != want {
|
if got, want := rr.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||||
@@ -104,14 +82,12 @@ func TestInitiatePaymentsByQuote_RejectsLegacyMetadataIntentRefFallbackByDefault
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInitiatePaymentsByQuote_RejectsLegacyMetadataIntentRefWhenDateGateExpired(t *testing.T) {
|
func TestInitiatePaymentsByQuote_RejectsDeprecatedIntentRefsField(t *testing.T) {
|
||||||
orgRef := bson.NewObjectID()
|
orgRef := bson.NewObjectID()
|
||||||
exec := &fakeExecutionClientForBatch{}
|
exec := &fakeExecutionClientForBatch{}
|
||||||
now := time.Date(2026, time.January, 10, 12, 0, 0, 0, time.UTC)
|
api := newBatchAPI(exec)
|
||||||
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"}}`
|
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","intentRefs":["intent-a","intent-b"]}`
|
||||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
||||||
if got, want := rr.Code, http.StatusBadRequest; got != want {
|
if got, want := rr.Code, http.StatusBadRequest; got != want {
|
||||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||||
@@ -121,67 +97,13 @@ func TestInitiatePaymentsByQuote_RejectsLegacyMetadataIntentRefWhenDateGateExpir
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
func newBatchAPI(exec executionClient) *PaymentAPI {
|
||||||
return newBatchAPIWithLegacyFallback(exec, false, time.Time{})
|
|
||||||
}
|
|
||||||
|
|
||||||
func newBatchAPIWithLegacyFallback(exec executionClient, enabled bool, until time.Time) *PaymentAPI {
|
|
||||||
return &PaymentAPI{
|
return &PaymentAPI{
|
||||||
logger: mlogger.Logger(zap.NewNop()),
|
logger: mlogger.Logger(zap.NewNop()),
|
||||||
execution: exec,
|
execution: exec,
|
||||||
enf: fakeEnforcerForBatch{allowed: true},
|
enf: fakeEnforcerForBatch{allowed: true},
|
||||||
oph: mutil.CreatePH(mservice.Organizations),
|
oph: mutil.CreatePH(mservice.Organizations),
|
||||||
permissionRef: bson.NewObjectID(),
|
permissionRef: bson.NewObjectID(),
|
||||||
legacyMetadataIntentRefFallbackEnabled: enabled,
|
|
||||||
legacyMetadataIntentRefFallbackUntil: until,
|
|
||||||
clock: time.Now,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ import (
|
|||||||
"crypto/tls"
|
"crypto/tls"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"strconv"
|
|
||||||
"strings"
|
"strings"
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
@@ -30,11 +29,6 @@ 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)
|
||||||
ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error)
|
ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error)
|
||||||
@@ -59,10 +53,6 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -93,7 +83,6 @@ 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)
|
||||||
@@ -107,7 +96,6 @@ 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))
|
||||||
}
|
}
|
||||||
@@ -303,79 +291,3 @@ 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")
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
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,40 +1,20 @@
|
|||||||
|
import 'package:json_annotation/json_annotation.dart';
|
||||||
import 'package:pshared/api/requests/payment/base.dart';
|
import 'package:pshared/api/requests/payment/base.dart';
|
||||||
|
|
||||||
|
part 'initiate_payments.g.dart';
|
||||||
|
|
||||||
|
|
||||||
|
@JsonSerializable()
|
||||||
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) => _$InitiatePaymentsRequestFromJson(json);
|
||||||
return InitiatePaymentsRequest(
|
|
||||||
idempotencyKey: json['idempotencyKey'] as String,
|
|
||||||
metadata: (json['metadata'] as Map<String, dynamic>?)?.map(
|
|
||||||
(key, value) => MapEntry(key, value as String),
|
|
||||||
),
|
|
||||||
quoteRef: json['quoteRef'] as String,
|
|
||||||
intentRef: json['intentRef'] as String?,
|
|
||||||
intentRefs: (json['intentRefs'] as List<dynamic>?)
|
|
||||||
?.map((value) => value as String)
|
|
||||||
.toList(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Map<String, dynamic> toJson() {
|
Map<String, dynamic> toJson() => _$InitiatePaymentsRequestToJson(this);
|
||||||
return <String, dynamic>{
|
|
||||||
'idempotencyKey': idempotencyKey,
|
|
||||||
'metadata': metadata,
|
|
||||||
'quoteRef': quoteRef,
|
|
||||||
if (intentRef != null) 'intentRef': intentRef,
|
|
||||||
if (intentRefs != null) 'intentRefs': intentRefs,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,6 @@ 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');
|
||||||
@@ -54,8 +52,6 @@ class MultiPaymentProvider extends ChangeNotifier {
|
|||||||
quoteRef,
|
quoteRef,
|
||||||
idempotencyKey: idempotencyKey,
|
idempotencyKey: idempotencyKey,
|
||||||
metadata: metadata,
|
metadata: metadata,
|
||||||
intentRef: intentRef,
|
|
||||||
intentRefs: intentRefs,
|
|
||||||
);
|
);
|
||||||
|
|
||||||
_setResource(
|
_setResource(
|
||||||
|
|||||||
@@ -37,8 +37,6 @@ 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',
|
||||||
@@ -47,8 +45,6 @@ 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(
|
||||||
|
|||||||
@@ -158,15 +158,15 @@ void main() {
|
|||||||
final request = InitiatePaymentRequest(
|
final request = InitiatePaymentRequest(
|
||||||
idempotencyKey: 'idem-2',
|
idempotencyKey: 'idem-2',
|
||||||
quoteRef: 'q-1',
|
quoteRef: 'q-1',
|
||||||
metadata: const {'intent_ref': 'intent-1'},
|
metadata: const {'client_payment_ref': 'cp-1'},
|
||||||
);
|
);
|
||||||
|
|
||||||
final json = request.toJson();
|
final json = request.toJson();
|
||||||
expect(json['idempotencyKey'], equals('idem-2'));
|
expect(json['idempotencyKey'], equals('idem-2'));
|
||||||
expect(json['quoteRef'], equals('q-1'));
|
expect(json['quoteRef'], equals('q-1'));
|
||||||
expect(
|
expect(
|
||||||
(json['metadata'] as Map<String, dynamic>)['intent_ref'],
|
(json['metadata'] as Map<String, dynamic>)['client_payment_ref'],
|
||||||
equals('intent-1'),
|
equals('cp-1'),
|
||||||
);
|
);
|
||||||
expect(json.containsKey('intent'), isTrue);
|
expect(json.containsKey('intent'), isTrue);
|
||||||
expect(json['intent'], isNull);
|
expect(json['intent'], isNull);
|
||||||
@@ -176,35 +176,18 @@ 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'),
|
||||||
);
|
);
|
||||||
});
|
expect(json.containsKey('intentRef'), isFalse);
|
||||||
|
|
||||||
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);
|
expect(json.containsKey('intentRefs'), isFalse);
|
||||||
},
|
});
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -188,7 +188,6 @@ 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>{
|
||||||
@@ -198,7 +197,6 @@ 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;
|
||||||
@@ -274,20 +272,6 @@ 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