intent reference generation + propagation
This commit is contained in:
@@ -1,6 +1,8 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"strings"
|
||||
@@ -16,6 +18,11 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
const (
|
||||
fanoutIdempotencyHashLen = 16
|
||||
maxExecuteIdempotencyKey = 256
|
||||
)
|
||||
|
||||
func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Account, token *sresponse.TokenData) http.HandlerFunc {
|
||||
orgRef, err := a.oph.GetRef(r)
|
||||
if err != nil {
|
||||
@@ -39,26 +46,100 @@ func (a *PaymentAPI) initiatePaymentsByQuote(r *http.Request, account *model.Acc
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
req := &orchestrationv2.ExecutePaymentRequest{
|
||||
Meta: requestMeta(orgRef.Hex(), payload.IdempotencyKey),
|
||||
QuotationRef: strings.TrimSpace(payload.QuoteRef),
|
||||
IntentRef: metadataValue(payload.Metadata, "intent_ref"),
|
||||
ClientPaymentRef: metadataValue(payload.Metadata, "client_payment_ref"),
|
||||
intentSelectors, err := resolveExecutionIntentSelectors(payload, a.isLegacyMetadataIntentRefFallbackAllowed())
|
||||
if err != nil {
|
||||
return response.BadPayload(a.logger, a.Name(), err)
|
||||
}
|
||||
clientPaymentRef := metadataValue(payload.Metadata, "client_payment_ref")
|
||||
baseIdempotencyKey := strings.TrimSpace(payload.IdempotencyKey)
|
||||
quotationRef := strings.TrimSpace(payload.QuoteRef)
|
||||
|
||||
executeOne := func(idempotencyKey, intentRef string) (*orchestrationv2.Payment, error) {
|
||||
req := &orchestrationv2.ExecutePaymentRequest{
|
||||
Meta: requestMeta(orgRef.Hex(), idempotencyKey),
|
||||
QuotationRef: quotationRef,
|
||||
IntentRef: strings.TrimSpace(intentRef),
|
||||
ClientPaymentRef: clientPaymentRef,
|
||||
}
|
||||
resp, executeErr := a.execution.ExecutePayment(ctx, req)
|
||||
if executeErr != nil {
|
||||
return nil, executeErr
|
||||
}
|
||||
return resp.GetPayment(), nil
|
||||
}
|
||||
|
||||
resp, err := a.execution.ExecutePayment(ctx, req)
|
||||
payments := make([]*orchestrationv2.Payment, 0, max(1, len(intentSelectors)))
|
||||
if len(payload.IntentRefs) > 0 {
|
||||
for _, intentRef := range payload.IntentRefs {
|
||||
payment, executeErr := executeOne(deriveFanoutIdempotencyKey(baseIdempotencyKey, intentRef), intentRef)
|
||||
if executeErr != nil {
|
||||
a.logger.Warn("Failed to initiate batch payments", zap.Error(executeErr), zap.String("organization_ref", orgRef.Hex()))
|
||||
return grpcErrorResponse(a.logger, a.Name(), executeErr)
|
||||
}
|
||||
if payment != nil {
|
||||
payments = append(payments, payment)
|
||||
}
|
||||
}
|
||||
return sresponse.PaymentsResponse(a.logger, payments, token)
|
||||
}
|
||||
|
||||
intentRef := ""
|
||||
if len(intentSelectors) > 0 {
|
||||
intentRef = intentSelectors[0]
|
||||
}
|
||||
payment, err := executeOne(baseIdempotencyKey, intentRef)
|
||||
if err != nil {
|
||||
a.logger.Warn("Failed to initiate batch payments", zap.Error(err), zap.String("organization_ref", orgRef.Hex()))
|
||||
return grpcErrorResponse(a.logger, a.Name(), err)
|
||||
}
|
||||
|
||||
payments := make([]*orchestrationv2.Payment, 0, 1)
|
||||
if payment := resp.GetPayment(); payment != nil {
|
||||
if payment != nil {
|
||||
payments = append(payments, payment)
|
||||
}
|
||||
return sresponse.PaymentsResponse(a.logger, payments, token)
|
||||
}
|
||||
|
||||
func resolveExecutionIntentSelectors(payload *srequest.InitiatePayments, allowLegacyMetadataIntentRef bool) ([]string, error) {
|
||||
if payload == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if len(payload.IntentRefs) > 0 {
|
||||
return append([]string(nil), payload.IntentRefs...), nil
|
||||
}
|
||||
if intentRef := strings.TrimSpace(payload.IntentRef); intentRef != "" {
|
||||
return []string{intentRef}, nil
|
||||
}
|
||||
legacy := metadataValue(payload.Metadata, "intent_ref")
|
||||
if legacy == "" {
|
||||
return nil, nil
|
||||
}
|
||||
if allowLegacyMetadataIntentRef {
|
||||
return []string{legacy}, nil
|
||||
}
|
||||
return nil, merrors.InvalidArgument("metadata.intent_ref is no longer supported; use intentRef or intentRefs", "metadata.intent_ref")
|
||||
}
|
||||
|
||||
func deriveFanoutIdempotencyKey(baseIdempotencyKey, intentRef string) string {
|
||||
baseIdempotencyKey = strings.TrimSpace(baseIdempotencyKey)
|
||||
intentRef = strings.TrimSpace(intentRef)
|
||||
if baseIdempotencyKey == "" || intentRef == "" {
|
||||
return baseIdempotencyKey
|
||||
}
|
||||
sum := sha256.Sum256([]byte(intentRef))
|
||||
hash := hex.EncodeToString(sum[:])
|
||||
if len(hash) > fanoutIdempotencyHashLen {
|
||||
hash = hash[:fanoutIdempotencyHashLen]
|
||||
}
|
||||
suffix := ":i:" + hash
|
||||
if len(baseIdempotencyKey)+len(suffix) <= maxExecuteIdempotencyKey {
|
||||
return baseIdempotencyKey + suffix
|
||||
}
|
||||
if len(suffix) >= maxExecuteIdempotencyKey {
|
||||
return suffix[:maxExecuteIdempotencyKey]
|
||||
}
|
||||
prefixLen := maxExecuteIdempotencyKey - len(suffix)
|
||||
return baseIdempotencyKey[:prefixLen] + suffix
|
||||
}
|
||||
|
||||
func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments, error) {
|
||||
defer r.Body.Close()
|
||||
|
||||
@@ -68,6 +149,7 @@ func decodeInitiatePaymentsPayload(r *http.Request) (*srequest.InitiatePayments,
|
||||
}
|
||||
payload.IdempotencyKey = strings.TrimSpace(payload.IdempotencyKey)
|
||||
payload.QuoteRef = strings.TrimSpace(payload.QuoteRef)
|
||||
payload.IntentRef = strings.TrimSpace(payload.IntentRef)
|
||||
|
||||
if err := payload.Validate(); err != nil {
|
||||
return nil, err
|
||||
|
||||
268
api/server/internal/server/paymentapiimp/paybatch_test.go
Normal file
268
api/server/internal/server/paymentapiimp/paybatch_test.go
Normal file
@@ -0,0 +1,268 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/tech/sendico/pkg/auth"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
"github.com/tech/sendico/pkg/mservice"
|
||||
orchestrationv2 "github.com/tech/sendico/pkg/proto/payments/orchestration/v2"
|
||||
"github.com/tech/sendico/server/interface/api/srequest"
|
||||
"github.com/tech/sendico/server/interface/api/sresponse"
|
||||
mutil "github.com/tech/sendico/server/internal/mutil/param"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestInitiatePaymentsByQuote_FansOutByIntentRefs(t *testing.T) {
|
||||
orgRef := bson.NewObjectID()
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
api := newBatchAPI(exec)
|
||||
|
||||
body := `{"idempotencyKey":"idem-batch","quoteRef":"quote-1","intentRefs":["intent-a","intent-b"]}`
|
||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
||||
if got, want := rr.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||
}
|
||||
|
||||
if got, want := len(exec.executeReqs), 2; got != want {
|
||||
t.Fatalf("execute calls mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := exec.executeReqs[0].GetIntentRef(), "intent-a"; got != want {
|
||||
t.Fatalf("intent_ref[0] mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := exec.executeReqs[1].GetIntentRef(), "intent-b"; got != want {
|
||||
t.Fatalf("intent_ref[1] mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := exec.executeReqs[0].GetMeta().GetTrace().GetIdempotencyKey(), deriveFanoutIdempotencyKey("idem-batch", "intent-a"); got != want {
|
||||
t.Fatalf("idempotency[0] mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := exec.executeReqs[1].GetMeta().GetTrace().GetIdempotencyKey(), deriveFanoutIdempotencyKey("idem-batch", "intent-b"); got != want {
|
||||
t.Fatalf("idempotency[1] mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentsByQuote_UsesExplicitIntentRef(t *testing.T) {
|
||||
orgRef := bson.NewObjectID()
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
api := newBatchAPI(exec)
|
||||
|
||||
body := `{"idempotencyKey":"idem-single","quoteRef":"quote-1","intentRef":"intent-x"}`
|
||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
||||
if got, want := rr.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||
}
|
||||
|
||||
if got, want := len(exec.executeReqs), 1; got != want {
|
||||
t.Fatalf("execute calls mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := exec.executeReqs[0].GetIntentRef(), "intent-x"; got != want {
|
||||
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if got, want := exec.executeReqs[0].GetMeta().GetTrace().GetIdempotencyKey(), "idem-single"; got != want {
|
||||
t.Fatalf("idempotency mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentsByQuote_UsesLegacyMetadataIntentRefFallback(t *testing.T) {
|
||||
orgRef := bson.NewObjectID()
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
api := newBatchAPIWithLegacyFallback(exec, true, time.Time{})
|
||||
|
||||
body := `{"idempotencyKey":"idem-legacy","quoteRef":"quote-1","metadata":{"intent_ref":"intent-legacy"}}`
|
||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
||||
if got, want := rr.Code, http.StatusOK; got != want {
|
||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||
}
|
||||
|
||||
if got, want := len(exec.executeReqs), 1; got != want {
|
||||
t.Fatalf("execute calls mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
if got, want := exec.executeReqs[0].GetIntentRef(), "intent-legacy"; got != want {
|
||||
t.Fatalf("intent_ref mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentsByQuote_RejectsLegacyMetadataIntentRefFallbackByDefault(t *testing.T) {
|
||||
orgRef := bson.NewObjectID()
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
api := newBatchAPI(exec)
|
||||
|
||||
body := `{"idempotencyKey":"idem-legacy","quoteRef":"quote-1","metadata":{"intent_ref":"intent-legacy"}}`
|
||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
||||
if got, want := rr.Code, http.StatusBadRequest; got != want {
|
||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||
}
|
||||
if got := len(exec.executeReqs); got != 0 {
|
||||
t.Fatalf("expected no execute calls, got=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitiatePaymentsByQuote_RejectsLegacyMetadataIntentRefWhenDateGateExpired(t *testing.T) {
|
||||
orgRef := bson.NewObjectID()
|
||||
exec := &fakeExecutionClientForBatch{}
|
||||
now := time.Date(2026, time.January, 10, 12, 0, 0, 0, time.UTC)
|
||||
api := newBatchAPIWithLegacyFallback(exec, true, now.Add(-time.Minute))
|
||||
api.clock = func() time.Time { return now }
|
||||
|
||||
body := `{"idempotencyKey":"idem-legacy","quoteRef":"quote-1","metadata":{"intent_ref":"intent-legacy"}}`
|
||||
rr := invokeInitiatePaymentsByQuote(t, api, orgRef, body)
|
||||
if got, want := rr.Code, http.StatusBadRequest; got != want {
|
||||
t.Fatalf("status mismatch: got=%d want=%d body=%s", got, want, rr.Body.String())
|
||||
}
|
||||
if got := len(exec.executeReqs); got != 0 {
|
||||
t.Fatalf("expected no execute calls, got=%d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeriveFanoutIdempotencyKey_IsDeterministicAndBounded(t *testing.T) {
|
||||
a := deriveFanoutIdempotencyKey("idem-1", "intent-a")
|
||||
b := deriveFanoutIdempotencyKey("idem-1", "intent-a")
|
||||
if got, want := a, b; got != want {
|
||||
t.Fatalf("determinism mismatch: got=%q want=%q", got, want)
|
||||
}
|
||||
if a == "idem-1" {
|
||||
t.Fatalf("expected derived key to differ from base")
|
||||
}
|
||||
|
||||
c := deriveFanoutIdempotencyKey("idem-1", "intent-b")
|
||||
if c == a {
|
||||
t.Fatalf("expected different derived keys for different intents")
|
||||
}
|
||||
|
||||
longBase := strings.Repeat("a", 400)
|
||||
long := deriveFanoutIdempotencyKey(longBase, "intent-a")
|
||||
if got, want := len(long), maxExecuteIdempotencyKey; got != want {
|
||||
t.Fatalf("length mismatch: got=%d want=%d", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecutionIntentSelectors_PrefersExplicitSelectors(t *testing.T) {
|
||||
payload := &srequest.InitiatePayments{
|
||||
IntentRefs: []string{"intent-a", "intent-b"},
|
||||
IntentRef: "intent-single",
|
||||
PaymentBase: srequest.PaymentBase{
|
||||
Metadata: map[string]string{"intent_ref": "intent-legacy"},
|
||||
},
|
||||
}
|
||||
got, err := resolveExecutionIntentSelectors(payload, false)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || len(got) != 2 {
|
||||
t.Fatalf("unexpected selectors: %#v", got)
|
||||
}
|
||||
if got[0] != "intent-a" || got[1] != "intent-b" {
|
||||
t.Fatalf("unexpected selectors order/value: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecutionIntentSelectors_RejectsLegacyMetadataSelectorWhenDisabled(t *testing.T) {
|
||||
payload := &srequest.InitiatePayments{
|
||||
PaymentBase: srequest.PaymentBase{
|
||||
Metadata: map[string]string{"intent_ref": "intent-legacy"},
|
||||
},
|
||||
}
|
||||
_, err := resolveExecutionIntentSelectors(payload, false)
|
||||
if !errors.Is(err, merrors.ErrInvalidArg) {
|
||||
t.Fatalf("expected invalid argument, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveExecutionIntentSelectors_UsesLegacyMetadataSelectorWhenEnabled(t *testing.T) {
|
||||
payload := &srequest.InitiatePayments{
|
||||
PaymentBase: srequest.PaymentBase{
|
||||
Metadata: map[string]string{"intent_ref": "intent-legacy"},
|
||||
},
|
||||
}
|
||||
got, err := resolveExecutionIntentSelectors(payload, true)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got == nil || len(got) != 1 || got[0] != "intent-legacy" {
|
||||
t.Fatalf("unexpected selectors: %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func newBatchAPI(exec executionClient) *PaymentAPI {
|
||||
return newBatchAPIWithLegacyFallback(exec, false, time.Time{})
|
||||
}
|
||||
|
||||
func newBatchAPIWithLegacyFallback(exec executionClient, enabled bool, until time.Time) *PaymentAPI {
|
||||
return &PaymentAPI{
|
||||
logger: mlogger.Logger(zap.NewNop()),
|
||||
execution: exec,
|
||||
enf: fakeEnforcerForBatch{allowed: true},
|
||||
oph: mutil.CreatePH(mservice.Organizations),
|
||||
permissionRef: bson.NewObjectID(),
|
||||
legacyMetadataIntentRefFallbackEnabled: enabled,
|
||||
legacyMetadataIntentRefFallbackUntil: until,
|
||||
clock: time.Now,
|
||||
}
|
||||
}
|
||||
|
||||
func invokeInitiatePaymentsByQuote(t *testing.T, api *PaymentAPI, orgRef bson.ObjectID, body string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
|
||||
req := httptest.NewRequest(http.MethodPost, "/by-multiquote", bytes.NewBufferString(body))
|
||||
routeCtx := chi.NewRouteContext()
|
||||
routeCtx.URLParams.Add("organizations_ref", orgRef.Hex())
|
||||
req = req.WithContext(context.WithValue(req.Context(), chi.RouteCtxKey, routeCtx))
|
||||
|
||||
rr := httptest.NewRecorder()
|
||||
handler := api.initiatePaymentsByQuote(req, &model.Account{}, &sresponse.TokenData{
|
||||
Token: "token",
|
||||
Expiration: time.Now().UTC().Add(time.Hour),
|
||||
})
|
||||
handler.ServeHTTP(rr, req)
|
||||
return rr
|
||||
}
|
||||
|
||||
type fakeExecutionClientForBatch struct {
|
||||
executeReqs []*orchestrationv2.ExecutePaymentRequest
|
||||
}
|
||||
|
||||
func (f *fakeExecutionClientForBatch) ExecutePayment(_ context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error) {
|
||||
f.executeReqs = append(f.executeReqs, req)
|
||||
return &orchestrationv2.ExecutePaymentResponse{
|
||||
Payment: &orchestrationv2.Payment{PaymentRef: bson.NewObjectID().Hex()},
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (*fakeExecutionClientForBatch) ListPayments(context.Context, *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error) {
|
||||
return &orchestrationv2.ListPaymentsResponse{}, nil
|
||||
}
|
||||
|
||||
func (*fakeExecutionClientForBatch) Close() error { return nil }
|
||||
|
||||
type fakeEnforcerForBatch struct {
|
||||
allowed bool
|
||||
}
|
||||
|
||||
func (f fakeEnforcerForBatch) Enforce(context.Context, bson.ObjectID, bson.ObjectID, bson.ObjectID, bson.ObjectID, model.Action) (bool, error) {
|
||||
return f.allowed, nil
|
||||
}
|
||||
|
||||
func (fakeEnforcerForBatch) EnforceBatch(context.Context, []model.PermissionBoundStorable, bson.ObjectID, model.Action) (map[bson.ObjectID]bool, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeEnforcerForBatch) GetRoles(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, error) {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func (fakeEnforcerForBatch) GetPermissions(context.Context, bson.ObjectID, bson.ObjectID) ([]model.Role, []model.Permission, error) {
|
||||
return nil, nil, nil
|
||||
}
|
||||
|
||||
var _ auth.Enforcer = (*fakeEnforcerForBatch)(nil)
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"crypto/tls"
|
||||
"fmt"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
@@ -29,6 +30,11 @@ import (
|
||||
"google.golang.org/grpc/credentials/insecure"
|
||||
)
|
||||
|
||||
const (
|
||||
envLegacyMetadataIntentRefFallbackEnabled = "PAYMENTS_LEGACY_METADATA_INTENT_REF_FALLBACK"
|
||||
envLegacyMetadataIntentRefFallbackUntil = "PAYMENTS_LEGACY_METADATA_INTENT_REF_FALLBACK_UNTIL"
|
||||
)
|
||||
|
||||
type executionClient interface {
|
||||
ExecutePayment(ctx context.Context, req *orchestrationv2.ExecutePaymentRequest) (*orchestrationv2.ExecutePaymentResponse, error)
|
||||
ListPayments(ctx context.Context, req *orchestrationv2.ListPaymentsRequest) (*orchestrationv2.ListPaymentsResponse, error)
|
||||
@@ -52,6 +58,10 @@ type PaymentAPI struct {
|
||||
refreshMu sync.RWMutex
|
||||
refreshEvent *discovery.RefreshEvent
|
||||
|
||||
legacyMetadataIntentRefFallbackEnabled bool
|
||||
legacyMetadataIntentRefFallbackUntil time.Time
|
||||
clock func() time.Time
|
||||
|
||||
permissionRef bson.ObjectID
|
||||
}
|
||||
|
||||
@@ -82,6 +92,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
|
||||
logger: apiCtx.Logger().Named(mservice.Payments),
|
||||
enf: apiCtx.Permissions().Enforcer(),
|
||||
oph: mutil.CreatePH(mservice.Organizations),
|
||||
clock: time.Now,
|
||||
}
|
||||
|
||||
desc, err := apiCtx.Permissions().GetPolicyDescription(context.Background(), mservice.Payments)
|
||||
@@ -95,6 +106,7 @@ func CreateAPI(apiCtx eapi.API) (*PaymentAPI, error) {
|
||||
p.logger.Error("Failed to initialize payment orchestrator client", zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
p.configureLegacyMetadataIntentRefFallback()
|
||||
if err := p.initDiscoveryClient(apiCtx.Config()); err != nil {
|
||||
p.logger.Warn("Failed to initialize discovery client", zap.Error(err))
|
||||
}
|
||||
@@ -290,3 +302,79 @@ func (a *PaymentAPI) initDiscoveryClient(cfg *eapi.Config) error {
|
||||
}()
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *PaymentAPI) configureLegacyMetadataIntentRefFallback() {
|
||||
enabled := false
|
||||
enabledRaw := strings.TrimSpace(os.Getenv(envLegacyMetadataIntentRefFallbackEnabled))
|
||||
if enabledRaw != "" {
|
||||
parsed, err := strconv.ParseBool(enabledRaw)
|
||||
if err != nil {
|
||||
a.logger.Warn("Invalid legacy metadata intent_ref fallback flag, disabling fallback",
|
||||
zap.String("env", envLegacyMetadataIntentRefFallbackEnabled),
|
||||
zap.String("value", enabledRaw),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else {
|
||||
enabled = parsed
|
||||
}
|
||||
}
|
||||
|
||||
until := time.Time{}
|
||||
untilRaw := strings.TrimSpace(os.Getenv(envLegacyMetadataIntentRefFallbackUntil))
|
||||
if untilRaw != "" {
|
||||
parsed, err := parseLegacyMetadataIntentRefFallbackDeadline(untilRaw)
|
||||
if err != nil {
|
||||
a.logger.Warn("Invalid legacy metadata intent_ref fallback deadline, ignoring deadline",
|
||||
zap.String("env", envLegacyMetadataIntentRefFallbackUntil),
|
||||
zap.String("value", untilRaw),
|
||||
zap.Error(err),
|
||||
)
|
||||
} else {
|
||||
until = parsed
|
||||
}
|
||||
}
|
||||
|
||||
a.legacyMetadataIntentRefFallbackEnabled = enabled
|
||||
a.legacyMetadataIntentRefFallbackUntil = until
|
||||
|
||||
if !enabled {
|
||||
return
|
||||
}
|
||||
fields := []zap.Field{
|
||||
zap.String("env_flag", envLegacyMetadataIntentRefFallbackEnabled),
|
||||
zap.String("env_until", envLegacyMetadataIntentRefFallbackUntil),
|
||||
}
|
||||
if !until.IsZero() {
|
||||
fields = append(fields, zap.Time("until_utc", until.UTC()))
|
||||
}
|
||||
a.logger.Warn("Legacy metadata.intent_ref fallback is enabled for /by-multiquote", fields...)
|
||||
}
|
||||
|
||||
func (a *PaymentAPI) isLegacyMetadataIntentRefFallbackAllowed() bool {
|
||||
if a == nil || !a.legacyMetadataIntentRefFallbackEnabled {
|
||||
return false
|
||||
}
|
||||
if a.legacyMetadataIntentRefFallbackUntil.IsZero() {
|
||||
return true
|
||||
}
|
||||
now := time.Now().UTC()
|
||||
if a.clock != nil {
|
||||
now = a.clock().UTC()
|
||||
}
|
||||
return now.Before(a.legacyMetadataIntentRefFallbackUntil.UTC())
|
||||
}
|
||||
|
||||
func parseLegacyMetadataIntentRefFallbackDeadline(value string) (time.Time, error) {
|
||||
raw := strings.TrimSpace(value)
|
||||
if raw == "" {
|
||||
return time.Time{}, merrors.InvalidArgument("deadline is required")
|
||||
}
|
||||
if ts, err := time.Parse(time.RFC3339, raw); err == nil {
|
||||
return ts.UTC(), nil
|
||||
}
|
||||
if date, err := time.Parse("2006-01-02", raw); err == nil {
|
||||
// Date-only values are treated as inclusive; disable fallback at the next UTC midnight.
|
||||
return date.UTC().Add(24 * time.Hour), nil
|
||||
}
|
||||
return time.Time{}, merrors.InvalidArgument("deadline must be RFC3339 or YYYY-MM-DD")
|
||||
}
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
package paymentapiimp
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestParseLegacyMetadataIntentRefFallbackDeadline(t *testing.T) {
|
||||
t.Run("parses RFC3339", func(t *testing.T) {
|
||||
got, err := parseLegacyMetadataIntentRefFallbackDeadline("2026-02-26T12:00:00Z")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := time.Date(2026, time.February, 26, 12, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("deadline mismatch: got=%s want=%s", got.UTC(), want.UTC())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("parses date-only as inclusive UTC day", func(t *testing.T) {
|
||||
got, err := parseLegacyMetadataIntentRefFallbackDeadline("2026-02-26")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
want := time.Date(2026, time.February, 27, 0, 0, 0, 0, time.UTC)
|
||||
if !got.Equal(want) {
|
||||
t.Fatalf("deadline mismatch: got=%s want=%s", got.UTC(), want.UTC())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("rejects invalid format", func(t *testing.T) {
|
||||
if _, err := parseLegacyMetadataIntentRefFallbackDeadline("26-02-2026"); err == nil {
|
||||
t.Fatal("expected error")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestIsLegacyMetadataIntentRefFallbackAllowed(t *testing.T) {
|
||||
now := time.Date(2026, time.February, 26, 12, 0, 0, 0, time.UTC)
|
||||
|
||||
t.Run("disabled", func(t *testing.T) {
|
||||
api := &PaymentAPI{
|
||||
legacyMetadataIntentRefFallbackEnabled: false,
|
||||
clock: func() time.Time { return now },
|
||||
}
|
||||
if api.isLegacyMetadataIntentRefFallbackAllowed() {
|
||||
t.Fatal("expected disabled fallback")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enabled without deadline", func(t *testing.T) {
|
||||
api := &PaymentAPI{
|
||||
legacyMetadataIntentRefFallbackEnabled: true,
|
||||
clock: func() time.Time { return now },
|
||||
}
|
||||
if !api.isLegacyMetadataIntentRefFallbackAllowed() {
|
||||
t.Fatal("expected enabled fallback")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enabled with future deadline", func(t *testing.T) {
|
||||
api := &PaymentAPI{
|
||||
legacyMetadataIntentRefFallbackEnabled: true,
|
||||
legacyMetadataIntentRefFallbackUntil: now.Add(time.Minute),
|
||||
clock: func() time.Time { return now },
|
||||
}
|
||||
if !api.isLegacyMetadataIntentRefFallbackAllowed() {
|
||||
t.Fatal("expected enabled fallback before deadline")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("enabled with past deadline", func(t *testing.T) {
|
||||
api := &PaymentAPI{
|
||||
legacyMetadataIntentRefFallbackEnabled: true,
|
||||
legacyMetadataIntentRefFallbackUntil: now.Add(-time.Minute),
|
||||
clock: func() time.Time { return now },
|
||||
}
|
||||
if api.isLegacyMetadataIntentRefFallbackAllowed() {
|
||||
t.Fatal("expected disabled fallback after deadline")
|
||||
}
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user