diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go index f4d4b7e3..fdd35346 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute.go @@ -25,10 +25,9 @@ func (s *svc) ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePa if req != nil && req.GetMeta() != nil { orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef()) } - logger.Debug("Starting Execute payment", + logger.Debug("Starting payment execution", zap.String("organization_ref", orgRef), 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()) != ""), ) 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))...) return } - logger.Debug("Completed Execute payment", fields...) + logger.Debug("Completed payment execution", fields...) }(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) if err != nil { return nil, err @@ -104,7 +107,6 @@ func (s *svc) prepareExecute(req *orchestrationv2.ExecutePaymentRequest) (*reqva fingerprint, err := s.idempotency.Fingerprint(idem.FPInput{ OrganizationRef: requestCtx.OrganizationRef, QuotationRef: requestCtx.QuotationRef, - IntentRef: requestCtx.IntentRef, ClientPaymentRef: requestCtx.ClientPaymentRef, }) if err != nil { @@ -119,7 +121,6 @@ func mapExecuteReq(req *orchestrationv2.ExecutePaymentRequest) *reqval.Req { } out := &reqval.Req{ QuotationRef: req.GetQuotationRef(), - IntentRef: req.GetIntentRef(), ClientPaymentRef: req.GetClientPaymentRef(), } 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{ OrganizationRef: requestCtx.OrganizationRef, QuotationRef: existing.QuotationRef, - IntentRef: existing.IntentSnapshot.Ref, ClientPaymentRef: existing.ClientPaymentRef, }) 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{ OrganizationID: requestCtx.OrganizationID, QuotationRef: requestCtx.QuotationRef, - IntentRef: requestCtx.IntentRef, }) if err != nil { return nil, nil, remapResolveError(err) @@ -271,7 +270,7 @@ func remapIdempotencyError(err error) error { func remapResolveError(err error) error { switch { - case errors.Is(err, qsnap.ErrIntentRefRequired), errors.Is(err, qsnap.ErrIntentRefNotFound): + case errors.Is(err, qsnap.ErrBatchQuoteUnsupported): return merrors.InvalidArgument(err.Error()) default: return err @@ -285,7 +284,6 @@ func mustFingerprint(idemSvc idem.Service, requestCtx *reqval.Ctx) string { value, err := idemSvc.Fingerprint(idem.FPInput{ OrganizationRef: requestCtx.OrganizationRef, QuotationRef: requestCtx.QuotationRef, - IntentRef: requestCtx.IntentRef, ClientPaymentRef: requestCtx.ClientPaymentRef, }) if err != nil { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go index c97919e5..22cc2e9d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/execute_batch.go @@ -27,7 +27,7 @@ func (s *svc) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.Exec if req != nil && req.GetMeta() != nil { orgRef = strings.TrimSpace(req.GetMeta().GetOrganizationRef()) } - logger.Debug("Starting ExecuteBatchPayment", + logger.Debug("Starting batch payment execution", zap.String("organization_ref", orgRef), zap.String("quotation_ref", strings.TrimSpace(req.GetQuotationRef())), 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))...) return } - logger.Debug("Completed ExecuteBatchPayment", fields...) + logger.Debug("Completed batch payment execution", fields...) }(time.Now()) requestCtx, err := s.prepareBatchExecute(req) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go index 1bbed458..64564630 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/psvc/service_e2e_test.go @@ -41,7 +41,6 @@ func TestExecutePayment_EndToEndSyncSettled(t *testing.T) { Meta: testMeta(env.orgID, "idem-sync"), QuotationRef: "quote-sync", ClientPaymentRef: "client-1", - IntentRef: "intent-sync", }) if err != nil { t.Fatalf("ExecutePayment returned error: %v", err) @@ -89,7 +88,6 @@ func TestExecutePayment_IdempotencyMismatch(t *testing.T) { Meta: testMeta(env.orgID, "idem-shared"), QuotationRef: "quote-idem", ClientPaymentRef: "client-a", - IntentRef: "intent-idem", }) if err != nil { t.Fatalf("first ExecutePayment returned error: %v", err) @@ -99,14 +97,35 @@ func TestExecutePayment_IdempotencyMismatch(t *testing.T) { Meta: testMeta(env.orgID, "idem-shared"), QuotationRef: "quote-idem", ClientPaymentRef: "client-b", - IntentRef: "intent-idem", }) if !errors.Is(err, merrors.ErrInvalidArg) { 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) { step := req.StepExecution step.State = agg.StepStateCompleted @@ -120,9 +139,9 @@ func TestExecutePayment_BatchQuoteRequiresIntentRef(t *testing.T) { ClientPaymentRef: "client-batch", }) 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) } } @@ -142,7 +161,6 @@ func TestExecutePayment_RetryThenSuccess(t *testing.T) { Meta: testMeta(env.orgID, "idem-retry"), QuotationRef: "quote-retry", ClientPaymentRef: "client-retry", - IntentRef: "intent-retry", }) if err != nil { t.Fatalf("ExecutePayment returned error: %v", err) @@ -186,7 +204,6 @@ func TestReconcileExternal_AdvancesAsyncPaymentToSettled(t *testing.T) { Meta: testMeta(env.orgID, "idem-async"), QuotationRef: "quote-async", ClientPaymentRef: "client-async", - IntentRef: "intent-async", }) if err != nil { t.Fatalf("ExecutePayment returned error: %v", err) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/errors.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/errors.go index e451e1ac..0bf10199 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/errors.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/errors.go @@ -3,10 +3,9 @@ package qsnap import "errors" var ( - ErrQuoteNotFound = errors.New("quotation_ref not found") - ErrQuoteExpired = errors.New("quotation_ref expired") - ErrQuoteNotExecutable = errors.New("quotation_ref is not executable") - ErrQuoteShapeMismatch = errors.New("quotation payload shape mismatch") - ErrIntentRefRequired = errors.New("intent_ref is required for batch quotation") - ErrIntentRefNotFound = errors.New("intent_ref not found in quotation") + ErrQuoteNotFound = errors.New("quotation_ref not found") + ErrQuoteExpired = errors.New("quotation_ref expired") + ErrQuoteNotExecutable = errors.New("quotation_ref is not executable") + ErrQuoteShapeMismatch = errors.New("quotation payload shape mismatch") + ErrBatchQuoteUnsupported = errors.New("batch quotation is not supported for ExecutePayment; use ExecuteBatchPayment") ) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go index a3d65a3e..4ed3886a 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/module.go @@ -25,13 +25,11 @@ type Resolver interface { type Input struct { OrganizationID bson.ObjectID QuotationRef string - IntentRef string } // Output contains extracted canonical snapshots for execution. type Output struct { QuotationRef string - IntentRef string IntentSnapshot model.PaymentIntent QuoteSnapshot *model.PaymentQuoteSnapshot } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go index 4f03aff1..a868ac93 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_errors_test.go @@ -142,7 +142,6 @@ func TestResolve_ShapeMismatch(t *testing.T) { }, Input{ OrganizationID: bson.NewObjectID(), QuotationRef: "quote-ref", - IntentRef: "intent-1", }) if !errors.Is(err, ErrQuoteShapeMismatch) { t.Fatalf("expected ErrQuoteShapeMismatch, got %v", err) diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go index a6b65fcb..0512fd96 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/resolve_shapes_test.go @@ -45,7 +45,6 @@ func TestResolve_SingleShapeOK(t *testing.T) { }, Input{ OrganizationID: orgID, QuotationRef: "stored-quote-ref", - IntentRef: "intent-1", }) if err != nil { 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 { 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 { t.Fatal("expected quote snapshot") } @@ -108,9 +104,6 @@ func TestResolve_ArrayShapeOK(t *testing.T) { if out == nil { 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 { 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) orgID := bson.NewObjectID() @@ -153,91 +146,11 @@ func TestResolve_MultiShapeSelectByIntentRef(t *testing.T) { }, Input{ OrganizationID: orgID, QuotationRef: "batch-quote-ref", - IntentRef: "intent-b", }) - if err != nil { - t.Fatalf("Resolve returned error: %v", err) + if out != nil { + t.Fatalf("expected nil output, got %#v", out) } - if out == nil { - t.Fatal("expected output") - } - 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) + if !errors.Is(err, ErrBatchQuoteUnsupported) { + t.Fatalf("expected ErrBatchQuoteUnsupported, got %v", err) } } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go index 1b9413b1..6fc89177 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/qsnap/service.go @@ -35,14 +35,12 @@ func (s *svc) Resolve( logger.Debug("Starting Resolve", zap.String("organization_ref", in.OrganizationID.Hex()), zap.String("quotation_ref", strings.TrimSpace(in.QuotationRef)), - zap.String("intent_ref", strings.TrimSpace(in.IntentRef)), ) defer func(start time.Time) { fields := []zap.Field{zap.Int64("duration_ms", time.Since(start).Milliseconds())} if out != nil { fields = append(fields, zap.String("quotation_ref", strings.TrimSpace(out.QuotationRef)), - zap.String("intent_ref", strings.TrimSpace(out.IntentRef)), ) } if err != nil { @@ -63,7 +61,6 @@ func (s *svc) Resolve( if quoteRef == "" { return nil, merrors.InvalidArgument("quotation_ref is required") } - intentRef := strings.TrimSpace(in.IntentRef) record, err := store.GetByRef(ctx, in.OrganizationID, quoteRef) if err != nil { @@ -76,7 +73,7 @@ func (s *svc) Resolve( return nil, ErrQuoteNotFound } - item, err := resolveRecordItem(record, intentRef) + item, err := resolveRecordItem(record) if err != nil { return nil, err } @@ -95,7 +92,6 @@ func (s *svc) Resolve( out = &Output{ QuotationRef: outputRef, - IntentRef: firstNonEmpty(strings.TrimSpace(item.Intent.Ref), intentRef), IntentSnapshot: item.Intent, QuoteSnapshot: item.Quote, } @@ -165,7 +161,7 @@ func (s *svc) ResolveAll( if err := ensureExecutable(record, item.Status, s.now().UTC()); err != nil { return nil, err } - resolved, err := resolveItem(item, "") + resolved, err := resolveItem(item) if err != nil { 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 { 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 { 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: - index, err := resolveBatchItemIndex(record.Items, intentRef) - if err != nil { - return nil, err + if len(record.Items) != 1 { + return nil, ErrBatchQuoteUnsupported } - return resolveItem(record.Items[index], intentRef) + return resolveItem(record.Items[0]) default: return nil, xerr.Wrapf(ErrQuoteShapeMismatch, "request shape is invalid") } } -func resolveBatchItemIndex(items []*model.PaymentQuoteItemV2, intentRef string) (int, 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) { +func resolveItem(item *model.PaymentQuoteItemV2) (*resolvedQuoteItem, error) { if item == 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 { 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) if err != nil { @@ -310,32 +279,6 @@ func resolveItem(item *model.PaymentQuoteItemV2, intentRef string) (*resolvedQuo }, 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) { var dst model.PaymentIntent if err := bsonClone(src, &dst); err != nil { diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go index 242b2d7f..faf40b5d 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/module.go @@ -16,7 +16,6 @@ type Validator interface { type Req struct { Meta *Meta QuotationRef string - IntentRef string ClientPaymentRef string } @@ -37,7 +36,6 @@ type Ctx struct { OrganizationID bson.ObjectID IdempotencyKey string QuotationRef string - IntentRef string ClientPaymentRef string } diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go index cf323994..b0693849 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator.go @@ -14,7 +14,6 @@ import ( const ( maxIdempotencyKeyLen = 256 maxQuotationRefLen = 128 - maxIntentRefLen = 128 maxClientRefLen = 128 ) @@ -33,7 +32,6 @@ func (s *svc) Validate(req *Req) (out *Ctx, err error) { logger.Debug("Starting Validate", zap.String("organization_ref", orgRefIn), 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)) != ""), ) defer func(start time.Time) { @@ -42,7 +40,6 @@ func (s *svc) Validate(req *Req) (out *Ctx, err error) { fields = append(fields, zap.String("organization_ref", out.OrganizationRef), zap.String("quotation_ref", out.QuotationRef), - zap.String("intent_ref", out.IntentRef), 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 } - 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) if err != nil { return nil, err @@ -98,7 +90,6 @@ func (s *svc) Validate(req *Req) (out *Ctx, err error) { OrganizationID: orgID, IdempotencyKey: idempotencyKey, QuotationRef: quotationRef, - IntentRef: intentRef, ClientPaymentRef: clientPaymentRef, } return out, nil diff --git a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator_test.go b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator_test.go index b5676074..f6d27a43 100644 --- a/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator_test.go +++ b/api/payments/orchestrator/internal/service/orchestrationv2/reqval/validator_test.go @@ -19,7 +19,6 @@ func TestValidate_OK(t *testing.T) { }, }, QuotationRef: " quote-ref-1 ", - IntentRef: " intent-ref-1 ", ClientPaymentRef: " client.ref-1 ", }) if err != nil { @@ -40,9 +39,6 @@ func TestValidate_OK(t *testing.T) { if got, want := ctx.QuotationRef, "quote-ref-1"; 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 { 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 != "" { 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) { orgID := bson.NewObjectID().Hex() tooLongIdem := "x" + strings.Repeat("a", maxIdempotencyKeyLen) tooLongQuote := "q" + strings.Repeat("a", maxQuotationRefLen) - tooLongIntent := "i" + strings.Repeat("a", maxIntentRefLen) tooLongClient := "c" + strings.Repeat("a", maxClientRefLen) tests := []struct { @@ -193,28 +185,6 @@ func TestValidate_Errors(t *testing.T) { 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", req: &Req{ diff --git a/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go b/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go index 7002542a..3739ac6a 100644 --- a/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go +++ b/api/payments/orchestrator/internal/service/orchestrator/service_v2_test.go @@ -15,7 +15,7 @@ import ( func TestV2GRPCServerExecutePayment_MapsInvalidArgument(t *testing.T) { 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()) _, 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 { 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) } } diff --git a/api/server/interface/api/srequest/payment.go b/api/server/interface/api/srequest/payment.go index d8e2ae5f..b7c51c8b 100644 --- a/api/server/interface/api/srequest/payment.go +++ b/api/server/interface/api/srequest/payment.go @@ -107,9 +107,7 @@ func (r InitiatePayment) Validate() error { type InitiatePayments struct { PaymentBase `json:",inline"` - QuoteRef string `json:"quoteRef,omitempty"` - IntentRef string `json:"intentRef,omitempty"` - IntentRefs []string `json:"intentRefs,omitempty"` + QuoteRef string `json:"quoteRef,omitempty"` } func (r *InitiatePayments) Validate() error { @@ -120,35 +118,9 @@ func (r *InitiatePayments) Validate() error { return err } r.QuoteRef = strings.TrimSpace(r.QuoteRef) - r.IntentRef = strings.TrimSpace(r.IntentRef) - hasIntentRefsField := r.IntentRefs != nil - - normalizedIntentRefs := make([]string, 0, len(r.IntentRefs)) - seen := make(map[string]struct{}, len(r.IntentRefs)) - for _, value := range r.IntentRefs { - intentRef := strings.TrimSpace(value) - if intentRef == "" { - return merrors.InvalidArgument("intentRefs must not contain empty values", "intentRefs") - } - if _, exists := seen[intentRef]; exists { - return merrors.InvalidArgument("intentRefs must contain unique values", "intentRefs") - } - seen[intentRef] = struct{}{} - normalizedIntentRefs = append(normalizedIntentRefs, intentRef) - } - if hasIntentRefsField && len(normalizedIntentRefs) == 0 { - return merrors.InvalidArgument("intentRefs must not be empty", "intentRefs") - } - r.IntentRefs = normalizedIntentRefs - if len(r.IntentRefs) == 0 { - r.IntentRefs = nil - } if r.QuoteRef == "" { return merrors.InvalidArgument("quoteRef is required", "quoteRef") } - if r.IntentRef != "" && len(r.IntentRefs) > 0 { - return merrors.DataConflict("intentRef and intentRefs are mutually exclusive") - } return nil } diff --git a/api/server/interface/api/srequest/payment_validate_test.go b/api/server/interface/api/srequest/payment_validate_test.go index 9517f127..52d569e2 100644 --- a/api/server/interface/api/srequest/payment_validate_test.go +++ b/api/server/interface/api/srequest/payment_validate_test.go @@ -28,29 +28,11 @@ 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) { +func TestInitiatePaymentsValidate(t *testing.T) { + t.Run("accepts quoteRef", 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) @@ -58,66 +40,14 @@ func TestInitiatePaymentsValidateIntentSelectors(t *testing.T) { 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) { + t.Run("rejects missing quoteRef", func(t *testing.T) { req := &InitiatePayments{ PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, - QuoteRef: "quote-1", - IntentRef: "intent-a", - IntentRefs: []string{"intent-b"}, } if err := req.Validate(); err == nil { t.Fatal("expected error") } }) - - t.Run("rejects empty intentRefs item", func(t *testing.T) { - req := &InitiatePayments{ - PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, - QuoteRef: "quote-1", - IntentRefs: []string{"intent-a", " "}, - } - if err := req.Validate(); err == nil { - t.Fatal("expected error") - } - }) - - t.Run("rejects empty intentRefs list", func(t *testing.T) { - req := &InitiatePayments{ - PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, - QuoteRef: "quote-1", - IntentRefs: []string{}, - } - if err := req.Validate(); err == nil { - t.Fatal("expected error") - } - }) - - t.Run("rejects duplicate intentRefs", func(t *testing.T) { - req := &InitiatePayments{ - PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, - QuoteRef: "quote-1", - IntentRefs: []string{"intent-a", " intent-a "}, - } - if err := req.Validate(); err == nil { - t.Fatal("expected error") - } - }) - - t.Run("accepts no selectors for backward compatibility", func(t *testing.T) { - req := &InitiatePayments{ - PaymentBase: PaymentBase{IdempotencyKey: "idem-1"}, - QuoteRef: "quote-1", - } - if err := req.Validate(); err != nil { - t.Fatalf("expected no error, got %v", err) - } - }) } diff --git a/api/server/internal/server/paymentapiimp/pay.go b/api/server/internal/server/paymentapiimp/pay.go index 42e98468..f74ddb76 100644 --- a/api/server/internal/server/paymentapiimp/pay.go +++ b/api/server/internal/server/paymentapiimp/pay.go @@ -61,7 +61,9 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to } 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 { applyCustomerIP(payload.Intent, r.RemoteAddr) intent, err := mapQuoteIntent(payload.Intent) @@ -82,15 +84,11 @@ func (a *PaymentAPI) initiatePayment(r *http.Request, account *model.Account, to if quotationRef == "" { 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{ Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey), QuotationRef: quotationRef, - IntentRef: intentRef, ClientPaymentRef: metadataValue(payload.Metadata, "client_payment_ref"), } diff --git a/api/server/internal/server/paymentapiimp/pay_test.go b/api/server/internal/server/paymentapiimp/pay_test.go new file mode 100644 index 00000000..7b5b7e0f --- /dev/null +++ b/api/server/internal/server/paymentapiimp/pay_test.go @@ -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 +} diff --git a/api/server/internal/server/paymentapiimp/paybatch.go b/api/server/internal/server/paymentapiimp/paybatch.go index 9a3cbc69..2b01d42c 100644 --- a/api/server/internal/server/paymentapiimp/paybatch.go +++ b/api/server/internal/server/paymentapiimp/paybatch.go @@ -39,100 +39,39 @@ func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Acc return response.BadPayload(a.logger, a.Name(), err) } - intentSelectors, err := resolveExecutionIntentSelectors(payload, a.isLegacyMetadataIntentRefFallbackAllowed()) - if err != nil { - return response.BadPayload(a.logger, a.Name(), err) - } clientPaymentRef := metadataValue(payload.Metadata, "client_payment_ref") - baseIdempotencyKey := strings.TrimSpace(payload.IdempotencyKey) + idempotencyKey := strings.TrimSpace(payload.IdempotencyKey) quotationRef := strings.TrimSpace(payload.QuoteRef) - executeOne := func(idempotencyKey, intentRef string) (*orchestrationv2.Payment, error) { - req := &orchestrationv2.ExecutePaymentRequest{ - Meta: requestMeta(orgRef.Hex(), idempotencyKey), - QuotationRef: quotationRef, - IntentRef: strings.TrimSpace(intentRef), - ClientPaymentRef: clientPaymentRef, - } - resp, executeErr := a.execution.ExecutePayment(ctx, req) - if executeErr != nil { - return nil, executeErr - } - return resp.GetPayment(), nil + req := &orchestrationv2.ExecuteBatchPaymentRequest{ + Meta: requestMeta(orgRef.Hex(), idempotencyKey), + QuotationRef: quotationRef, + ClientPaymentRef: clientPaymentRef, } - - executeBatch := func(idempotencyKey string) ([]*orchestrationv2.Payment, error) { - req := &orchestrationv2.ExecuteBatchPaymentRequest{ - Meta: requestMeta(orgRef.Hex(), idempotencyKey), - QuotationRef: quotationRef, - ClientPaymentRef: clientPaymentRef, - } - resp, executeErr := 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) + resp, err := a.execution.ExecuteBatchPayment(ctx, req) if err != nil { a.logger.Warn("Failed to initiate batch payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex())) return grpcErrorResponse(a.logger, a.Name(), err) } - 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) } -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) { defer r.Body.Close() 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()) } payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey) payload.QuoteRef = strings.TrimSpace(payload.QuoteRef) - payload.IntentRef = strings.TrimSpace(payload.IntentRef) if err := payload.Validate(); err != nil { return nil, err diff --git a/api/server/internal/server/paymentapiimp/paybatch_test.go b/api/server/internal/server/paymentapiimp/paybatch_test.go index d21ba552..886f12e6 100644 --- a/api/server/internal/server/paymentapiimp/paybatch_test.go +++ b/api/server/internal/server/paymentapiimp/paybatch_test.go @@ -3,7 +3,6 @@ package paymentapiimp import ( "bytes" "context" - "errors" "net/http" "net/http/httptest" "testing" @@ -11,24 +10,22 @@ import ( "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_PassesIntentRefsInSingleExecuteCall(t *testing.T) { +func TestInitiatePaymentsByQuote_ExecutesBatchPayment(t *testing.T) { orgRef := bson.NewObjectID() exec := &fakeExecutionClientForBatch{} 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) if got, want := rr.Code, http.StatusOK; got != want { 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() exec := &fakeExecutionClientForBatch{} 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) 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 := len(exec.executeBatchReqs), 1; 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 { - t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want) + if got, want := exec.executeBatchReqs[0].GetClientPaymentRef(), "client-ref-1"; 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 { - t.Fatalf("idempotency mismatch: got=%q want=%q", got, want) + if got := len(exec.executeReqs); got != 0 { + t.Fatalf("expected no execute calls, got=%d", got) } } -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) { +func TestInitiatePaymentsByQuote_RejectsDeprecatedIntentRefField(t *testing.T) { orgRef := bson.NewObjectID() exec := &fakeExecutionClientForBatch{} 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) if got, want := rr.Code, http.StatusBadRequest; got != want { 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() 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 } + api := newBatchAPI(exec) - 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) if got, want := rr.Code, http.StatusBadRequest; got != want { 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 { - 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, + logger: mlogger.Logger(zap.NewNop()), + execution: exec, + enf: fakeEnforcerForBatch{allowed: true}, + oph: mutil.CreatePH(mservice.Organizations), + permissionRef: bson.NewObjectID(), } } diff --git a/api/server/internal/server/paymentapiimp/service.go b/api/server/internal/server/paymentapiimp/service.go index ea2d2415..76c18cd5 100644 --- a/api/server/internal/server/paymentapiimp/service.go +++ b/api/server/internal/server/paymentapiimp/service.go @@ -5,7 +5,6 @@ import ( "crypto/tls" "fmt" "os" - "strconv" "strings" "sync" "time" @@ -30,11 +29,6 @@ import ( "google.golang.org/grpc/credentials/insecure" ) -const ( - envLegacyMetadataIntentRefFallbackEnabled = "PAYMENTS_LEGACY_METADATA_INTENT_REF_FALLBACK" - envLegacyMetadataIntentRefFallbackUntil = "PAYMENTS_LEGACY_METADATA_INTENT_REF_FALLBACK_UNTIL" -) - type executionClient interface { ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) ExecuteBatchPayment(ctx context.Context, req *orchestrationv2.ExecuteBatchPaymentRequest) (*orchestrationv2.ExecuteBatchPaymentResponse, error) @@ -59,10 +53,6 @@ type PaymentAPI struct { refreshMu sync.RWMutex refreshEvent *discovery.RefreshEvent - legacyMetadataIntentRefFallbackEnabled bool - legacyMetadataIntentRefFallbackUntil time.Time - clock func() time.Time - permissionRef bson.ObjectID } @@ -93,7 +83,6 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) { logger: apiCtx.Logger().Named(mservice.Payments), enf: apiCtx.Permissions().Enforcer(), oph: mutil.CreatePH(mservice.Organizations), - clock: time.Now, } desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.Payments) @@ -107,7 +96,6 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) { p.logger.Error("Failed to initialize payment orchestrator client", zap.Error(err)) return nil, err } - p.configureLegacyMetadataIntentRefFallback() if err := p.initDiscoveryClient(apiCtx.Config()); err != nil { p.logger.Warn("Failed to initialize discovery client", zap.Error(err)) } @@ -303,79 +291,3 @@ func (a *PaymentAPI) initDiscoveryClient(cfg *eapi.Config) error { }() return nil } - -func (a *PaymentAPI) configureLegacyMetadataIntentRefFallback() { - enabled := false - enabledRaw := strings.TrimSpace(os.Getenv(envLegacyMetadataIntentRefFallbackEnabled)) - if enabledRaw != "" { - parsed, err := strconv.ParseBool(enabledRaw) - if err != nil { - a.logger.Warn("Invalid legacy metadata intent_ref fallback flag, disabling fallback", - zap.String("env", envLegacyMetadataIntentRefFallbackEnabled), - zap.String("value", enabledRaw), - zap.Error(err), - ) - } else { - enabled = parsed - } - } - - until := time.Time{} - untilRaw := strings.TrimSpace(os.Getenv(envLegacyMetadataIntentRefFallbackUntil)) - if untilRaw != "" { - parsed, err := parseLegacyMetadataIntentRefFallbackDeadline(untilRaw) - if err != nil { - a.logger.Warn("Invalid legacy metadata intent_ref fallback deadline, ignoring deadline", - zap.String("env", envLegacyMetadataIntentRefFallbackUntil), - zap.String("value", untilRaw), - zap.Error(err), - ) - } else { - until = parsed - } - } - - a.legacyMetadataIntentRefFallbackEnabled = enabled - a.legacyMetadataIntentRefFallbackUntil = until - - if !enabled { - return - } - fields := []zap.Field{ - zap.String("env_flag", envLegacyMetadataIntentRefFallbackEnabled), - zap.String("env_until", envLegacyMetadataIntentRefFallbackUntil), - } - if !until.IsZero() { - fields = append(fields, zap.Time("until_utc", until.UTC())) - } - a.logger.Warn("Legacy metadata.intent_ref fallback is enabled for /by-multiquote", fields...) -} - -func (a *PaymentAPI) isLegacyMetadataIntentRefFallbackAllowed() bool { - if a == nil || !a.legacyMetadataIntentRefFallbackEnabled { - return false - } - if a.legacyMetadataIntentRefFallbackUntil.IsZero() { - return true - } - now := time.Now().UTC() - if a.clock != nil { - now = a.clock().UTC() - } - return now.Before(a.legacyMetadataIntentRefFallbackUntil.UTC()) -} - -func parseLegacyMetadataIntentRefFallbackDeadline(value string) (time.Time, error) { - raw := strings.TrimSpace(value) - if raw == "" { - return time.Time{}, merrors.InvalidArgument("deadline is required") - } - if ts, err := time.Parse(time.RFC3339, raw); err == nil { - return ts.UTC(), nil - } - if date, err := time.Parse("2006-01-02", raw); err == nil { - // Date-only values are treated as inclusive; disable fallback at the next UTC midnight. - return date.UTC().Add(24 * time.Hour), nil - } - return time.Time{}, merrors.InvalidArgument("deadline must be RFC3339 or YYYY-MM-DD") -} diff --git a/api/server/internal/server/paymentapiimp/service_legacy_fallback_test.go b/api/server/internal/server/paymentapiimp/service_legacy_fallback_test.go deleted file mode 100644 index 9a06da07..00000000 --- a/api/server/internal/server/paymentapiimp/service_legacy_fallback_test.go +++ /dev/null @@ -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") - } - }) -} diff --git a/frontend/pshared/lib/api/requests/payment/initiate_payments.dart b/frontend/pshared/lib/api/requests/payment/initiate_payments.dart index 3e628c9b..9e4b08a0 100644 --- a/frontend/pshared/lib/api/requests/payment/initiate_payments.dart +++ b/frontend/pshared/lib/api/requests/payment/initiate_payments.dart @@ -1,40 +1,20 @@ +import 'package:json_annotation/json_annotation.dart'; import 'package:pshared/api/requests/payment/base.dart'; +part 'initiate_payments.g.dart'; + + +@JsonSerializable() class InitiatePaymentsRequest extends PaymentBaseRequest { final String quoteRef; - final String? intentRef; - final List? intentRefs; const InitiatePaymentsRequest({ required super.idempotencyKey, super.metadata, required this.quoteRef, - this.intentRef, - this.intentRefs, }); - factory InitiatePaymentsRequest.fromJson(Map json) { - return InitiatePaymentsRequest( - idempotencyKey: json['idempotencyKey'] as String, - metadata: (json['metadata'] as Map?)?.map( - (key, value) => MapEntry(key, value as String), - ), - quoteRef: json['quoteRef'] as String, - intentRef: json['intentRef'] as String?, - intentRefs: (json['intentRefs'] as List?) - ?.map((value) => value as String) - .toList(), - ); - } - + factory InitiatePaymentsRequest.fromJson(Map json) => _$InitiatePaymentsRequestFromJson(json); @override - Map toJson() { - return { - 'idempotencyKey': idempotencyKey, - 'metadata': metadata, - 'quoteRef': quoteRef, - if (intentRef != null) 'intentRef': intentRef, - if (intentRefs != null) 'intentRefs': intentRefs, - }; - } + Map toJson() => _$InitiatePaymentsRequestToJson(this); } diff --git a/frontend/pshared/lib/provider/payment/multiple/provider.dart b/frontend/pshared/lib/provider/payment/multiple/provider.dart index 59614155..5d8c228e 100644 --- a/frontend/pshared/lib/provider/payment/multiple/provider.dart +++ b/frontend/pshared/lib/provider/payment/multiple/provider.dart @@ -30,8 +30,6 @@ class MultiPaymentProvider extends ChangeNotifier { Future> pay({ String? idempotencyKey, Map? metadata, - String? intentRef, - List? intentRefs, }) async { if (!_organization.isOrganizationSet) { throw StateError('Organization is not set'); @@ -54,8 +52,6 @@ class MultiPaymentProvider extends ChangeNotifier { quoteRef, idempotencyKey: idempotencyKey, metadata: metadata, - intentRef: intentRef, - intentRefs: intentRefs, ); _setResource( diff --git a/frontend/pshared/lib/service/payment/multiple.dart b/frontend/pshared/lib/service/payment/multiple.dart index 7c3ea44d..1a5350b9 100644 --- a/frontend/pshared/lib/service/payment/multiple.dart +++ b/frontend/pshared/lib/service/payment/multiple.dart @@ -37,8 +37,6 @@ class MultiplePaymentsService { String quoteRef, { String? idempotencyKey, Map? metadata, - String? intentRef, - List? intentRefs, }) async { _logger.fine( 'Executing multiple payments for quote $quoteRef in $organizationRef', @@ -47,8 +45,6 @@ class MultiplePaymentsService { idempotencyKey: idempotencyKey ?? const Uuid().v4(), quoteRef: quoteRef, metadata: metadata, - intentRef: intentRef, - intentRefs: intentRefs, ); final response = await AuthorizationService.getPOSTResponse( diff --git a/frontend/pshared/test/payment/request_dto_format_test.dart b/frontend/pshared/test/payment/request_dto_format_test.dart index 38eb231b..e0ea1c5f 100644 --- a/frontend/pshared/test/payment/request_dto_format_test.dart +++ b/frontend/pshared/test/payment/request_dto_format_test.dart @@ -158,15 +158,15 @@ void main() { final request = InitiatePaymentRequest( idempotencyKey: 'idem-2', quoteRef: 'q-1', - metadata: const {'intent_ref': 'intent-1'}, + metadata: const {'client_payment_ref': 'cp-1'}, ); final json = request.toJson(); expect(json['idempotencyKey'], equals('idem-2')); expect(json['quoteRef'], equals('q-1')); expect( - (json['metadata'] as Map)['intent_ref'], - equals('intent-1'), + (json['metadata'] as Map)['client_payment_ref'], + equals('cp-1'), ); expect(json.containsKey('intent'), isTrue); expect(json['intent'], isNull); @@ -176,35 +176,18 @@ void main() { final request = InitiatePaymentsRequest( idempotencyKey: 'idem-3', quoteRef: 'q-2', - intentRefs: const ['intent-a', 'intent-b'], metadata: const {'client_payment_ref': 'cp-1'}, ); final json = request.toJson(); expect(json['idempotencyKey'], equals('idem-3')); expect(json['quoteRef'], equals('q-2')); - expect(json['intentRefs'], equals(const ['intent-a', 'intent-b'])); expect( (json['metadata'] as Map)['client_payment_ref'], equals('cp-1'), ); + expect(json.containsKey('intentRef'), isFalse); + expect(json.containsKey('intentRefs'), 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); - }, - ); }); } diff --git a/frontend/pweb/lib/providers/multiple_payouts.dart b/frontend/pweb/lib/providers/multiple_payouts.dart index 1957e64a..b497b8f6 100644 --- a/frontend/pweb/lib/providers/multiple_payouts.dart +++ b/frontend/pweb/lib/providers/multiple_payouts.dart @@ -188,7 +188,6 @@ class MultiplePayoutsProvider extends ChangeNotifier { try { _setState(MultiplePayoutsState.sending); _error = null; - final intentRefs = _quotedIntentRefs(); final result = await payment.pay( metadata: { @@ -198,7 +197,6 @@ class MultiplePayoutsProvider extends ChangeNotifier { 'upload_rows': _rows.length.toString(), ...?_uploadAmountMetadata(), }, - intentRefs: intentRefs.isEmpty ? null : intentRefs, ); _sentCount = result.length; @@ -274,20 +272,6 @@ class MultiplePayoutsProvider extends ChangeNotifier { List _quoteItems() => _quotation?.quotation?.items ?? const []; - List _quotedIntentRefs() { - final seen = {}; - final intentRefs = []; - for (final quote in _quoteItems()) { - final intentRef = (quote.intentRef ?? '').trim(); - if (intentRef.isEmpty || seen.contains(intentRef)) { - continue; - } - seen.add(intentRef); - intentRefs.add(intentRef); - } - return intentRefs; - } - @override void dispose() { _quotation?.removeListener(_onQuotationChanged);