642 lines
18 KiB
Go
642 lines
18 KiB
Go
package quotation
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/hex"
|
|
"errors"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/tech/sendico/payments/storage"
|
|
"github.com/tech/sendico/payments/storage/model"
|
|
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
|
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
|
"github.com/tech/sendico/pkg/merrors"
|
|
"github.com/tech/sendico/pkg/mlogger"
|
|
"github.com/tech/sendico/pkg/mservice"
|
|
"github.com/tech/sendico/pkg/mutil/mzap"
|
|
quotationv1 "github.com/tech/sendico/pkg/proto/payments/quotation/v1"
|
|
sharedv1 "github.com/tech/sendico/pkg/proto/payments/shared/v1"
|
|
"go.mongodb.org/mongo-driver/v2/bson"
|
|
"go.uber.org/zap"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
type quotePaymentCommand struct {
|
|
engine paymentEngine
|
|
logger mlogger.Logger
|
|
}
|
|
|
|
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 bson.ObjectID
|
|
intent *sharedv1.PaymentIntent
|
|
previewOnly bool
|
|
idempotencyKey string
|
|
hash string
|
|
}
|
|
|
|
type quotePaymentResult struct {
|
|
quote *sharedv1.PaymentQuote
|
|
executionNote string
|
|
}
|
|
|
|
func (h *quotePaymentCommand) Execute(
|
|
ctx context.Context,
|
|
req *quotationv1.QuotePaymentRequest,
|
|
) gsresponse.Responder[quotationv1.QuotePaymentResponse] {
|
|
|
|
if err := h.engine.EnsureRepository(ctx); err != nil {
|
|
return gsresponse.Unavailable[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
|
}
|
|
if req == nil {
|
|
return gsresponse.InvalidArgument[quotationv1.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[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
|
}
|
|
|
|
result, err := h.quotePayment(ctx, quotesStore, qc, req)
|
|
if err != nil {
|
|
return h.mapQuoteErr(err)
|
|
}
|
|
|
|
return gsresponse.Success("ationv1.QuotePaymentResponse{
|
|
IdempotencyKey: req.GetIdempotencyKey(),
|
|
Quote: result.quote,
|
|
ExecutionNote: result.executionNote,
|
|
})
|
|
}
|
|
|
|
func (h *quotePaymentCommand) prepareQuoteCtx(req *quotationv1.QuotePaymentRequest) (*quoteCtx, error) {
|
|
orgRef, orgID, err := validateMetaAndOrgRef(req.GetMeta())
|
|
if err != nil {
|
|
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 "eCtx{
|
|
orgID: orgRef,
|
|
orgRef: orgID,
|
|
intent: intent,
|
|
previewOnly: preview,
|
|
idempotencyKey: idem,
|
|
hash: hashQuoteRequest(req),
|
|
}, nil
|
|
}
|
|
|
|
func (h *quotePaymentCommand) quotePayment(
|
|
ctx context.Context,
|
|
quotesStore quotestorage.QuotesStore,
|
|
qc *quoteCtx,
|
|
req *quotationv1.QuotePaymentRequest,
|
|
) (*quotePaymentResult, 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 = bson.NewObjectID().Hex()
|
|
return "ePaymentResult{quote: quote}, nil
|
|
}
|
|
|
|
existing, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, qc.idempotencyKey)
|
|
if err != nil && !errors.Is(err, storage.ErrQuoteNotFound) && !errors.Is(err, merrors.ErrNoData) {
|
|
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 "ePaymentResult{
|
|
quote: modelQuoteToProto(existing.Quote),
|
|
executionNote: strings.TrimSpace(existing.ExecutionNote),
|
|
}, 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 := bson.NewObjectID().Hex()
|
|
quote.QuoteRef = quoteRef
|
|
|
|
executionNote := ""
|
|
plan, err := h.engine.BuildPaymentPlan(ctx, qc.orgRef, qc.intent, qc.idempotencyKey, quote)
|
|
if err != nil {
|
|
if errors.Is(err, merrors.ErrInvalidArg) {
|
|
executionNote = quoteNonExecutableNote(err)
|
|
h.logger.Info(
|
|
"Payment quote marked as non-executable",
|
|
mzap.ObjRef("org_ref", qc.orgRef),
|
|
zap.String("idempotency_key", qc.idempotencyKey),
|
|
zap.String("quote_ref", quoteRef),
|
|
zap.String("execution_note", executionNote),
|
|
)
|
|
} else {
|
|
h.logger.Warn(
|
|
"Failed to build payment plan",
|
|
zap.Error(err),
|
|
mzap.ObjRef("org_ref", qc.orgRef),
|
|
zap.String("idempotency_key", qc.idempotencyKey),
|
|
)
|
|
return nil, err
|
|
}
|
|
}
|
|
record := &model.PaymentQuoteRecord{
|
|
QuoteRef: quoteRef,
|
|
IdempotencyKey: qc.idempotencyKey,
|
|
Hash: qc.hash,
|
|
Intent: intentFromProto(qc.intent),
|
|
Quote: quoteSnapshotToModel(quote),
|
|
Plan: cloneStoredPaymentPlan(plan),
|
|
ExecutionNote: executionNote,
|
|
ExpiresAt: expiresAt,
|
|
}
|
|
record.SetID(bson.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.orgRef, qc.idempotencyKey)
|
|
if getErr == nil && existing != nil {
|
|
if existing.Hash != qc.hash {
|
|
return nil, errIdempotencyParamMismatch
|
|
}
|
|
return "ePaymentResult{
|
|
quote: modelQuoteToProto(existing.Quote),
|
|
executionNote: strings.TrimSpace(existing.ExecutionNote),
|
|
}, 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 "ePaymentResult{
|
|
quote: quote,
|
|
executionNote: executionNote,
|
|
}, nil
|
|
}
|
|
|
|
func (h *quotePaymentCommand) mapQuoteErr(err error) gsresponse.Responder[quotationv1.QuotePaymentResponse] {
|
|
if errors.Is(err, errIdempotencyRequired) ||
|
|
errors.Is(err, errPreviewWithIdempotency) ||
|
|
errors.Is(err, errIdempotencyParamMismatch) {
|
|
return gsresponse.InvalidArgument[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
|
}
|
|
return gsresponse.Auto[quotationv1.QuotePaymentResponse](h.logger, mservice.PaymentOrchestrator, err)
|
|
}
|
|
|
|
func quoteNonExecutableNote(err error) string {
|
|
reason := strings.TrimSpace(err.Error())
|
|
reason = strings.TrimPrefix(reason, merrors.ErrInvalidArg.Error()+":")
|
|
reason = strings.TrimSpace(reason)
|
|
if reason == "" {
|
|
return "quote will not be executed"
|
|
}
|
|
return "quote will not be executed: " + reason
|
|
}
|
|
|
|
// TODO: temprorarary hashing function, replace with a proper solution later
|
|
func hashQuoteRequest(req *quotationv1.QuotePaymentRequest) string {
|
|
cloned := proto.Clone(req).(*quotationv1.QuotePaymentRequest)
|
|
cloned.Meta = nil
|
|
cloned.IdempotencyKey = ""
|
|
cloned.PreviewOnly = false
|
|
|
|
b, err := proto.MarshalOptions{Deterministic: true}.Marshal(cloned)
|
|
if err != nil {
|
|
sum := sha256.Sum256([]byte("marshal_error"))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
sum := sha256.Sum256(b)
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
type quotePaymentsCommand struct {
|
|
engine paymentEngine
|
|
logger mlogger.Logger
|
|
}
|
|
|
|
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 bson.ObjectID
|
|
previewOnly bool
|
|
idempotencyKey string
|
|
hash string
|
|
intentCount int
|
|
}
|
|
|
|
func (h *quotePaymentsCommand) Execute(
|
|
ctx context.Context,
|
|
req *quotationv1.QuotePaymentsRequest,
|
|
) gsresponse.Responder[quotationv1.QuotePaymentsResponse] {
|
|
|
|
if err := h.engine.EnsureRepository(ctx); err != nil {
|
|
return gsresponse.Unavailable[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
|
}
|
|
if req == nil {
|
|
return gsresponse.InvalidArgument[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("nil request"))
|
|
}
|
|
|
|
qc, intents, err := h.prepare(req)
|
|
if err != nil {
|
|
return h.mapErr(err)
|
|
}
|
|
|
|
quotesStore, err := ensureQuotesStore(h.engine.Repository())
|
|
if err != nil {
|
|
return gsresponse.Unavailable[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
|
}
|
|
|
|
if qc.previewOnly {
|
|
quotes, _, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.orgRef, qc.idempotencyKey, intents, true)
|
|
if err != nil {
|
|
return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
|
}
|
|
aggregate, expiresAt, err := h.aggregate(quotes, expires)
|
|
if err != nil {
|
|
return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
|
}
|
|
_ = expiresAt
|
|
return gsresponse.Success("ationv1.QuotePaymentsResponse{
|
|
QuoteRef: "",
|
|
Aggregate: aggregate,
|
|
Quotes: quotes,
|
|
})
|
|
}
|
|
|
|
if rec, ok, err := h.tryReuse(ctx, quotesStore, qc); err != nil {
|
|
return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
|
} else if ok {
|
|
return gsresponse.Success(h.responseFromRecord(rec))
|
|
}
|
|
|
|
quotes, plans, expires, err := h.buildQuotes(ctx, req.GetMeta(), qc.orgRef, qc.idempotencyKey, intents, false)
|
|
if err != nil {
|
|
return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
|
}
|
|
|
|
aggregate, expiresAt, err := h.aggregate(quotes, expires)
|
|
if err != nil {
|
|
return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
|
}
|
|
|
|
quoteRef := bson.NewObjectID().Hex()
|
|
for _, q := range quotes {
|
|
if q != nil {
|
|
q.QuoteRef = quoteRef
|
|
}
|
|
}
|
|
|
|
rec, err := h.storeBatch(ctx, quotesStore, qc, quoteRef, intents, quotes, plans, expiresAt)
|
|
if err != nil {
|
|
return gsresponse.Auto[quotationv1.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("ationv1.QuotePaymentsResponse{
|
|
IdempotencyKey: req.GetIdempotencyKey(),
|
|
QuoteRef: quoteRef,
|
|
Aggregate: aggregate,
|
|
Quotes: quotes,
|
|
})
|
|
}
|
|
|
|
func (h *quotePaymentsCommand) prepare(req *quotationv1.QuotePaymentsRequest) (*quotePaymentsCtx, []*sharedv1.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 "ePaymentsCtx{
|
|
orgID: orgRefStr,
|
|
orgRef: orgID,
|
|
previewOnly: preview,
|
|
idempotencyKey: idem,
|
|
hash: hash,
|
|
intentCount: len(intents),
|
|
}, intents, nil
|
|
}
|
|
|
|
func (h *quotePaymentsCommand) tryReuse(
|
|
ctx context.Context,
|
|
quotesStore quotestorage.QuotesStore,
|
|
qc *quotePaymentsCtx,
|
|
) (*model.PaymentQuoteRecord, bool, error) {
|
|
|
|
rec, err := quotesStore.GetByIdempotencyKey(ctx, qc.orgRef, 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 *sharedv1.RequestMeta,
|
|
orgRef bson.ObjectID,
|
|
baseKey string,
|
|
intents []*sharedv1.PaymentIntent,
|
|
preview bool,
|
|
) ([]*sharedv1.PaymentQuote, []*model.PaymentPlan, []time.Time, error) {
|
|
|
|
quotes := make([]*sharedv1.PaymentQuote, 0, len(intents))
|
|
plans := make([]*model.PaymentPlan, 0, len(intents))
|
|
expires := make([]time.Time, 0, len(intents))
|
|
|
|
for i, intent := range intents {
|
|
perKey := perIntentIdempotencyKey(baseKey, i, len(intents))
|
|
req := "ationv1.QuotePaymentRequest{
|
|
Meta: meta,
|
|
IdempotencyKey: perKey,
|
|
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, nil, err
|
|
}
|
|
if !preview {
|
|
plan, err := h.engine.BuildPaymentPlan(ctx, orgRef, intent, perKey, q)
|
|
if err != nil {
|
|
h.logger.Warn(
|
|
"Failed to build payment plan (batch item)",
|
|
zap.Int("idx", i),
|
|
zap.Error(err),
|
|
)
|
|
return nil, nil, nil, err
|
|
}
|
|
plans = append(plans, cloneStoredPaymentPlan(plan))
|
|
}
|
|
quotes = append(quotes, q)
|
|
expires = append(expires, exp)
|
|
}
|
|
|
|
return quotes, plans, expires, nil
|
|
}
|
|
|
|
func (h *quotePaymentsCommand) aggregate(
|
|
quotes []*sharedv1.PaymentQuote,
|
|
expires []time.Time,
|
|
) (*sharedv1.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 quotestorage.QuotesStore,
|
|
qc *quotePaymentsCtx,
|
|
quoteRef string,
|
|
intents []*sharedv1.PaymentIntent,
|
|
quotes []*sharedv1.PaymentQuote,
|
|
plans []*model.PaymentPlan,
|
|
expiresAt time.Time,
|
|
) (*model.PaymentQuoteRecord, error) {
|
|
|
|
record := &model.PaymentQuoteRecord{
|
|
QuoteRef: quoteRef,
|
|
IdempotencyKey: qc.idempotencyKey,
|
|
Hash: qc.hash,
|
|
Intents: intentsFromProto(intents),
|
|
Quotes: quoteSnapshotsFromProto(quotes),
|
|
Plans: cloneStoredPaymentPlans(plans),
|
|
ExpiresAt: expiresAt,
|
|
}
|
|
record.SetID(bson.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) *quotationv1.QuotePaymentsResponse {
|
|
quotes := modelQuotesToProto(rec.Quotes)
|
|
for _, q := range quotes {
|
|
if q != nil {
|
|
q.QuoteRef = rec.QuoteRef
|
|
}
|
|
}
|
|
aggregate, _ := aggregatePaymentQuotes(quotes)
|
|
|
|
return "ationv1.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[quotationv1.QuotePaymentsResponse] {
|
|
if errors.Is(err, errBatchIdempotencyRequired) ||
|
|
errors.Is(err, errBatchPreviewWithIdempotency) ||
|
|
errors.Is(err, errBatchIdempotencyParamMismatch) ||
|
|
errors.Is(err, errBatchIdempotencyShapeMismatch) {
|
|
return gsresponse.InvalidArgument[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
|
}
|
|
return gsresponse.Auto[quotationv1.QuotePaymentsResponse](h.logger, mservice.PaymentOrchestrator, err)
|
|
}
|
|
|
|
func modelQuotesToProto(snaps []*model.PaymentQuoteSnapshot) []*sharedv1.PaymentQuote {
|
|
if len(snaps) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]*sharedv1.PaymentQuote, 0, len(snaps))
|
|
for _, s := range snaps {
|
|
out = append(out, modelQuoteToProto(s))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func hashQuotePaymentsIntents(intents []*sharedv1.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
|
|
}
|