idempotency key usage fix

This commit is contained in:
Stephan D
2026-01-21 15:23:50 +01:00
parent a15375f18e
commit d2e78356e6
48 changed files with 729 additions and 559 deletions

View File

@@ -2,7 +2,10 @@ package orchestrator
import (
"context"
"crypto/sha256"
"encoding/hex"
"errors"
"sort"
"strings"
"time"
@@ -16,6 +19,7 @@ import (
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
"go.mongodb.org/mongo-driver/bson/primitive"
"go.uber.org/zap"
"google.golang.org/protobuf/proto"
)
type quotePaymentCommand struct {
@@ -23,55 +27,194 @@ type quotePaymentCommand struct {
logger mlogger.Logger
}
func (h *quotePaymentCommand) Execute(ctx context.Context, req *orchestratorv1.QuotePaymentRequest) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
var (
errIdempotencyRequired = errors.New("idempotency key is required")
errPreviewWithIdempotency = errors.New("preview requests must not use idempotency key")
errIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters")
)
type quoteCtx struct {
orgID string
orgRef primitive.ObjectID
intent *orchestratorv1.PaymentIntent
previewOnly bool
idempotencyKey string
hash string
}
func (h *quotePaymentCommand) Execute(
ctx context.Context,
req *orchestratorv1.QuotePaymentRequest,
) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
qc, err := h.prepareQuoteCtx(req)
if err != nil {
return h.mapQuoteErr(err)
}
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteProto, err := h.quotePayment(ctx, quotesStore, qc, req)
if err != nil {
return h.mapQuoteErr(err)
}
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quoteProto})
}
func (h *quotePaymentCommand) prepareQuoteCtx(req *orchestratorv1.QuotePaymentRequest) (*quoteCtx, error) {
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
return nil, err
}
if err := requireNonNilIntent(req.GetIntent()); err != nil {
return nil, err
}
intent := req.GetIntent()
preview := req.GetPreviewOnly()
idem := strings.TrimSpace(req.GetIdempotencyKey())
if preview && idem != "" {
return nil, errPreviewWithIdempotency
}
if !preview && idem == "" {
return nil, errIdempotencyRequired
}
return &quoteCtx{
orgID: orgRef,
orgRef: orgID,
intent: intent,
previewOnly: preview,
idempotencyKey: idem,
hash: hashQuoteRequest(req),
}, nil
}
func (h *quotePaymentCommand) quotePayment(
ctx context.Context,
quotesStore storage.QuotesStore,
qc *quoteCtx,
req *orchestratorv1.QuotePaymentRequest,
) (*orchestratorv1.PaymentQuote, error) {
if qc.previewOnly {
quote, _, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req)
if err != nil {
h.logger.Warn("Failed to build preview payment quote", zap.Error(err), zap.String("org_ref", qc.orgID))
return nil, err
}
quote.QuoteRef = primitive.NewObjectID().Hex()
return quote, nil
}
existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.idempotencyKey)
if err != nil && !errors.Is(err, storage.ErrQuoteNotFound) {
h.logger.Warn(
"Failed to lookup quote by idempotency key",
zap.Error(err),
mzap.ObjRef("org_ref", qc.orgRef),
zap.String("idempotency_key", qc.idempotencyKey),
)
return nil, err
}
if existing != nil {
if existing.Hash != qc.hash {
return nil, errIdempotencyParamMismatch
}
h.logger.Debug(
"Idempotent quote reused",
mzap.ObjRef("org_ref", qc.orgRef),
zap.String("idempotency_key", qc.idempotencyKey),
zap.String("quote_ref", existing.QuoteRef),
)
return modelQuoteToProto(existing.Quote), nil
}
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, qc.orgID, req)
if err != nil {
h.logger.Warn(
"Failed to build payment quote",
zap.Error(err),
mzap.ObjRef("org_ref", qc.orgRef),
zap.String("idempotency_key", qc.idempotencyKey),
)
return nil, err
}
quoteRef := primitive.NewObjectID().Hex()
quote.QuoteRef = quoteRef
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
IdempotencyKey: qc.idempotencyKey,
Hash: qc.hash,
Intent: intentFromProto(qc.intent),
Quote: quoteSnapshotToModel(quote),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(qc.orgRef)
if err := quotesStore.Create(ctx, record); err != nil {
if errors.Is(err, storage.ErrDuplicateQuote) {
existing, getErr := quotesStore.GetByIdempotencyKey(ctx, qc.idempotencyKey)
if getErr == nil && existing != nil {
if existing.Hash != qc.hash {
return nil, errIdempotencyParamMismatch
}
return modelQuoteToProto(existing.Quote), nil
}
}
return nil, err
}
h.logger.Info(
"Stored payment quote",
zap.String("quote_ref", quoteRef),
mzap.ObjRef("org_ref", qc.orgRef),
zap.String("idempotency_key", qc.idempotencyKey),
zap.String("kind", qc.intent.GetKind().String()),
)
return quote, nil
}
func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[orchestratorv1.QuotePaymentResponse] {
if errors.Is(err, errIdempotencyRequired) ||
errors.Is(err, errPreviewWithIdempotency) ||
errors.Is(err, errIdempotencyParamMismatch) {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intent := req.GetIntent()
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgRef, req)
// TODO: temprorarary hashing function, replace with a proper solution later
func hashQuoteRequest(req *orchestratorv1.QuotePaymentRequest) string {
cloned := proto.Clone(req).(*orchestratorv1.QuotePaymentRequest)
cloned.Meta = nil
cloned.IdempotencyKey = ""
cloned.PreviewOnly = false
b, err := proto.MarshalOptions{Deterministic: true}.Marshal(cloned)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
sum := sha256.Sum256([]byte("marshal_error"))
return hex.EncodeToString(sum[:])
}
if !req.GetPreviewOnly() {
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef := primitive.NewObjectID().Hex()
quote.QuoteRef = quoteRef
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
Intent: intentFromProto(intent),
Quote: quoteSnapshotToModel(quote),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgID)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info(
"Stored payment quote",
zap.String("quote_ref", quoteRef),
mzap.ObjRef("org_ref", orgID),
zap.String("idempotency_key", strings.TrimSpace(req.GetIdempotencyKey())),
zap.String("kind", intent.GetKind().String()),
)
}
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
type quotePaymentsCommand struct {
@@ -79,76 +222,98 @@ type quotePaymentsCommand struct {
logger mlogger.Logger
}
func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.QuotePaymentsRequest) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] {
var (
errBatchIdempotencyRequired = errors.New("idempotency key is required")
errBatchPreviewWithIdempotency = errors.New("preview requests must not use idempotency key")
errBatchIdempotencyParamMismatch = errors.New("idempotency key reuse with different parameters")
errBatchIdempotencyShapeMismatch = errors.New("idempotency key already used for a different quote shape")
)
type quotePaymentsCtx struct {
orgID string
orgRef primitive.ObjectID
previewOnly bool
idempotencyKey string
hash string
intentCount int
}
func (h *quotePaymentsCommand) Execute(
ctx context.Context,
req *orchestratorv1.QuotePaymentsRequest,
) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] {
if err := h.engine.EnsureRepository(ctx); err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if req == nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
}
orgID, orgRef, err := validateMetaAndOrgRef(req.GetMeta())
qc, intents, err := h.prepare(req)
if err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
intents := req.GetIntents()
if len(intents) == 0 {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intents are required"))
return h.mapErr(err)
}
baseKey := strings.TrimSpace(req.GetIdempotencyKey())
quotes := make([]*orchestratorv1.PaymentQuote, 0, len(intents))
expires := make([]time.Time, 0, len(intents))
for i, intent := range intents {
if err := requireNonNilIntent(intent); err != nil {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteReq := &orchestratorv1.QuotePaymentRequest{
Meta: req.GetMeta(),
IdempotencyKey: perIntentIdempotencyKey(baseKey, i, len(intents)),
Intent: intent,
PreviewOnly: req.GetPreviewOnly(),
}
quote, expiresAt, err := h.engine.BuildPaymentQuote(ctx, orgID, quoteReq)
quotesStore, err := ensureQuotesStore(h.engine.Repository())
if err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if qc.previewOnly {
quotes, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.idempotencyKey, intents, true)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quotes = append(quotes, quote)
expires = append(expires, expiresAt)
}
aggregate, err := aggregatePaymentQuotes(quotes)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InternalWrap(err, "quote aggregation failed"))
}
expiresAt, ok := minQuoteExpiry(expires)
if !ok {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.Internal("quote expiry missing"))
}
quoteRef := ""
if !req.GetPreviewOnly() {
quotesStore, err := ensureQuotesStore(h.engine.Repository())
aggregate, expiresAt, err := h.aggregate(quotes, expires)
if err != nil {
return gsresponse.Unavailable[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef = primitive.NewObjectID().Hex()
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
Intents: intentsFromProto(intents),
Quotes: quoteSnapshotsFromProto(quotes),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(orgRef)
if err := quotesStore.Create(ctx, record); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
h.logger.Info("Stored payment quotes",
zap.String("quote_ref", quoteRef), mzap.ObjRef("org_ref", orgRef),
zap.String("idempotency_key", baseKey), zap.Int("quote_count", len(quotes)),
)
_ = expiresAt
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
QuoteRef: "",
Aggregate: aggregate,
Quotes: quotes,
})
}
if rec, ok, err := h.tryReuse(ctx, quotesStore, qc); err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
} else if ok {
return gsresponse.Success(h.responseFromRecord(rec))
}
quotes, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.idempotencyKey, intents, false)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
aggregate, expiresAt, err := h.aggregate(quotes, expires)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
quoteRef := primitive.NewObjectID().Hex()
for _, q := range quotes {
if q != nil {
q.QuoteRef = quoteRef
}
}
rec, err := h.storeBatch(ctx, quotesStore, qc, quoteRef, intents, quotes, expiresAt)
if err != nil {
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
if rec != nil {
return gsresponse.Success(h.responseFromRecord(rec))
}
h.logger.Info(
"Stored payment quotes",
h.logFields(qc, quoteRef, expiresAt, len(quotes))...,
)
return gsresponse.Success(&orchestratorv1.QuotePaymentsResponse{
QuoteRef: quoteRef,
Aggregate: aggregate,
@@ -156,6 +321,256 @@ func (h *quotePaymentsCommand) Execute(ctx context.Context, req *orchestratorv1.
})
}
func (h *quotePaymentsCommand) prepare(req *orchestratorv1.QuotePaymentsRequest) (*quotePaymentsCtx, []*orchestratorv1.PaymentIntent, error) {
orgRefStr, orgID, err := validateMetaAndOrgRef(req.GetMeta())
if err != nil {
return nil, nil, err
}
intents := req.GetIntents()
if len(intents) == 0 {
return nil, nil, merrors.InvalidArgument("intents are required")
}
for _, intent := range intents {
if err := requireNonNilIntent(intent); err != nil {
return nil, nil, err
}
}
preview := req.GetPreviewOnly()
idem := strings.TrimSpace(req.GetIdempotencyKey())
if preview && idem != "" {
return nil, nil, errBatchPreviewWithIdempotency
}
if !preview && idem == "" {
return nil, nil, errBatchIdempotencyRequired
}
hash, err := hashQuotePaymentsIntents(intents)
if err != nil {
return nil, nil, err
}
return &quotePaymentsCtx{
orgID: orgRefStr,
orgRef: orgID,
previewOnly: preview,
idempotencyKey: idem,
hash: hash,
intentCount: len(intents),
}, intents, nil
}
func (h *quotePaymentsCommand) tryReuse(
ctx context.Context,
quotesStore storage.QuotesStore,
qc *quotePaymentsCtx,
) (*model.PaymentQuoteRecord, bool, error) {
rec, err := quotesStore.GetByIdempotencyKey(ctx, qc.idempotencyKey)
if err != nil {
if errors.Is(err, storage.ErrQuoteNotFound) {
return nil, false, nil
}
h.logger.Warn(
"Failed to lookup payment quotes by idempotency key",
h.logFields(qc, "", time.Time{}, 0)...,
)
return nil, false, err
}
if len(rec.Quotes) == 0 {
return nil, false, errBatchIdempotencyShapeMismatch
}
if rec.Hash != qc.hash {
return nil, false, errBatchIdempotencyParamMismatch
}
h.logger.Debug(
"Idempotent payment quotes reused",
h.logFields(qc, rec.QuoteRef, rec.ExpiresAt, len(rec.Quotes))...,
)
return rec, true, nil
}
func (h *quotePaymentsCommand) buildQuotes(
ctx context.Context,
meta *orchestratorv1.RequestMeta,
baseKey string,
intents []*orchestratorv1.PaymentIntent,
preview bool,
) ([]*orchestratorv1.PaymentQuote, []time.Time, error) {
quotes := make([]*orchestratorv1.PaymentQuote, 0, len(intents))
expires := make([]time.Time, 0, len(intents))
for i, intent := range intents {
req := &orchestratorv1.QuotePaymentRequest{
Meta: meta,
IdempotencyKey: perIntentIdempotencyKey(baseKey, i, len(intents)),
Intent: intent,
PreviewOnly: preview,
}
q, exp, err := h.engine.BuildPaymentQuote(ctx, meta.GetOrganizationRef(), req)
if err != nil {
h.logger.Warn(
"Failed to build payment quote (batch item)",
zap.Int("idx", i),
zap.Error(err),
)
return nil, nil, err
}
quotes = append(quotes, q)
expires = append(expires, exp)
}
return quotes, expires, nil
}
func (h *quotePaymentsCommand) aggregate(
quotes []*orchestratorv1.PaymentQuote,
expires []time.Time,
) (*orchestratorv1.PaymentQuoteAggregate, time.Time, error) {
agg, err := aggregatePaymentQuotes(quotes)
if err != nil {
return nil, time.Time{}, merrors.InternalWrap(err, "quote aggregation failed")
}
expiresAt, ok := minQuoteExpiry(expires)
if !ok {
return nil, time.Time{}, merrors.Internal("quote expiry missing")
}
return agg, expiresAt, nil
}
func (h *quotePaymentsCommand) storeBatch(
ctx context.Context,
quotesStore storage.QuotesStore,
qc *quotePaymentsCtx,
quoteRef string,
intents []*orchestratorv1.PaymentIntent,
quotes []*orchestratorv1.PaymentQuote,
expiresAt time.Time,
) (*model.PaymentQuoteRecord, error) {
record := &model.PaymentQuoteRecord{
QuoteRef: quoteRef,
IdempotencyKey: qc.idempotencyKey,
Hash: qc.hash,
Intents: intentsFromProto(intents),
Quotes: quoteSnapshotsFromProto(quotes),
ExpiresAt: expiresAt,
}
record.SetID(primitive.NewObjectID())
record.SetOrganizationRef(qc.orgRef)
if err := quotesStore.Create(ctx, record); err != nil {
if errors.Is(err, storage.ErrDuplicateQuote) {
rec, ok, reuseErr := h.tryReuse(ctx, quotesStore, qc)
if reuseErr != nil {
return nil, reuseErr
}
if ok {
return rec, nil
}
return nil, err
}
return nil, err
}
return nil, nil
}
func (h *quotePaymentsCommand) responseFromRecord(rec *model.PaymentQuoteRecord) *orchestratorv1.QuotePaymentsResponse {
quotes := modelQuotesToProto(rec.Quotes)
for _, q := range quotes {
if q != nil {
q.QuoteRef = rec.QuoteRef
}
}
aggregate, _ := aggregatePaymentQuotes(quotes)
return &orchestratorv1.QuotePaymentsResponse{
QuoteRef: rec.QuoteRef,
Aggregate: aggregate,
Quotes: quotes,
}
}
func (h *quotePaymentsCommand) logFields(qc *quotePaymentsCtx, quoteRef string, expiresAt time.Time, quoteCount int) []zap.Field {
fields := []zap.Field{
mzap.ObjRef("org_ref", qc.orgRef),
zap.String("org_ref_str", qc.orgID),
zap.String("idempotency_key", qc.idempotencyKey),
zap.String("hash", qc.hash),
zap.Bool("preview_only", qc.previewOnly),
zap.Int("intent_count", qc.intentCount),
}
if quoteRef != "" {
fields = append(fields, zap.String("quote_ref", quoteRef))
}
if !expiresAt.IsZero() {
fields = append(fields, zap.Time("expires_at", expiresAt))
}
if quoteCount > 0 {
fields = append(fields, zap.Int("quote_count", quoteCount))
}
return fields
}
func (h *quotePaymentsCommand) mapErr(err error) gsresponse.Responder[orchestratorv1.QuotePaymentsResponse] {
if errors.Is(err, errBatchIdempotencyRequired) ||
errors.Is(err, errBatchPreviewWithIdempotency) ||
errors.Is(err, errBatchIdempotencyParamMismatch) ||
errors.Is(err, errBatchIdempotencyShapeMismatch) {
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
return gsresponse.Auto[orchestratorv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
}
func modelQuotesToProto(snaps []*model.PaymentQuoteSnapshot) []*orchestratorv1.PaymentQuote {
if len(snaps) == 0 {
return nil
}
out := make([]*orchestratorv1.PaymentQuote, 0, len(snaps))
for _, s := range snaps {
out = append(out, modelQuoteToProto(s))
}
return out
}
func hashQuotePaymentsIntents(intents []*orchestratorv1.PaymentIntent) (string, error) {
type item struct {
Idx int
H [32]byte
}
items := make([]item, 0, len(intents))
for i, intent := range intents {
b, err := proto.MarshalOptions{Deterministic: true}.Marshal(intent)
if err != nil {
return "", err
}
items = append(items, item{Idx: i, H: sha256.Sum256(b)})
}
sort.Slice(items, func(i, j int) bool { return items[i].Idx < items[j].Idx })
h := sha256.New()
h.Write([]byte("quote-payments-fp/v1"))
h.Write([]byte{0})
for _, it := range items {
h.Write(it.H[:])
h.Write([]byte{0})
}
return hex.EncodeToString(h.Sum(nil)), nil
}
type initiatePaymentsCommand struct {
engine paymentEngine
logger mlogger.Logger

View File

@@ -429,3 +429,15 @@ func (s *helperQuotesStore) GetByRef(_ context.Context, _ primitive.ObjectID, re
}
return nil, storage.ErrQuoteNotFound
}
func (s *helperQuotesStore) GetByIdempotencyKey(_ context.Context, ref string) (*model.PaymentQuoteRecord, error) {
if s.records == nil {
return nil, storage.ErrQuoteNotFound
}
for _, rec := range s.records {
if rec.IdempotencyKey == ref {
return rec, nil
}
}
return nil, storage.ErrQuoteNotFound
}

View File

@@ -423,6 +423,18 @@ func (s *stubQuotesStore) GetByRef(ctx context.Context, orgRef primitive.ObjectI
return nil, storage.ErrQuoteNotFound
}
func (s *stubQuotesStore) GetByIdempotencyKey(ctx context.Context, idempotencyKey string) (*model.PaymentQuoteRecord, error) {
if s.quotes == nil {
return nil, storage.ErrQuoteNotFound
}
for _, q := range s.quotes {
if q.IdempotencyKey == idempotencyKey {
return q, nil
}
}
return nil, storage.ErrQuoteNotFound
}
type stubRoutesStore struct {
routes []*model.PaymentRoute
}