quotation bff
This commit is contained in:
@@ -54,8 +54,8 @@ require (
|
||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||
go.uber.org/multierr v1.11.0 // indirect
|
||||
go.yaml.in/yaml/v2 v2.4.3 // indirect
|
||||
golang.org/x/crypto v0.45.0 // indirect
|
||||
golang.org/x/net v0.47.0 // indirect
|
||||
golang.org/x/crypto v0.46.0 // indirect
|
||||
golang.org/x/net v0.48.0 // indirect
|
||||
golang.org/x/sync v0.19.0 // indirect
|
||||
golang.org/x/sys v0.39.0 // indirect
|
||||
golang.org/x/text v0.32.0 // indirect
|
||||
|
||||
@@ -179,15 +179,15 @@ go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
|
||||
go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
|
||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||
golang.org/x/crypto v0.45.0 h1:jMBrvKuj23MTlT0bQEOBcAE0mjg8mK9RXFhRH6nyF3Q=
|
||||
golang.org/x/crypto v0.45.0/go.mod h1:XTGrrkGJve7CYK7J8PEww4aY7gM3qMCElcJQ8n8JdX4=
|
||||
golang.org/x/crypto v0.46.0 h1:cKRW/pmt1pKAfetfu+RCEvjvZkA9RimPbh7bhFjGVBU=
|
||||
golang.org/x/crypto v0.46.0/go.mod h1:Evb/oLKmMraqjZ2iQTwDwvCtJkczlDuTmdJXoZVzqU0=
|
||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||
golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
|
||||
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
|
||||
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
|
||||
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
|
||||
golang.org/x/net v0.47.0 h1:Mx+4dIFzqraBXUugkia1OOvlD6LemFo1ALMHjrXDOhY=
|
||||
golang.org/x/net v0.47.0/go.mod h1:/jNxtkgq5yWUGYkaZGqo27cfGZ1c5Nen03aYrrKpVRU=
|
||||
golang.org/x/net v0.48.0 h1:zyQRTTrjc33Lhh0fBgT/H3oZq9WuvRR5gPC70xpDiQU=
|
||||
golang.org/x/net v0.48.0/go.mod h1:+ndRgGjkh8FGtu1w1FGbEC31if4VrNVMuKTgcAAnQRY=
|
||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
|
||||
@@ -97,6 +97,7 @@ func quoteSnapshotToModel(src *orchestratorv1.PaymentQuote) *model.PaymentQuoteS
|
||||
FXQuote: cloneFXQuote(src.GetFxQuote()),
|
||||
NetworkFee: cloneNetworkEstimate(src.GetNetworkFee()),
|
||||
FeeQuoteToken: strings.TrimSpace(src.GetFeeQuoteToken()),
|
||||
QuoteRef: strings.TrimSpace(src.GetQuoteRef()),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -220,6 +221,7 @@ func modelQuoteToProto(src *model.PaymentQuoteSnapshot) *orchestratorv1.PaymentQ
|
||||
FxQuote: cloneFXQuote(src.FXQuote),
|
||||
NetworkFee: cloneNetworkEstimate(src.NetworkFee),
|
||||
FeeQuoteToken: src.FeeQuoteToken,
|
||||
QuoteRef: strings.TrimSpace(src.QuoteRef),
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -19,13 +19,13 @@ import (
|
||||
"google.golang.org/protobuf/types/known/timestamppb"
|
||||
)
|
||||
|
||||
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, error) {
|
||||
func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*orchestratorv1.PaymentQuote, time.Time, error) {
|
||||
intent := req.GetIntent()
|
||||
amount := intent.GetAmount()
|
||||
baseAmount := cloneMoney(amount)
|
||||
feeQuote, err := s.quoteFees(ctx, orgRef, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
feeTotal := extractFeeTotal(feeQuote.GetLines(), amount.GetCurrency())
|
||||
|
||||
@@ -33,7 +33,7 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc
|
||||
if shouldEstimateNetworkFee(intent) {
|
||||
networkFee, err = s.estimateNetworkFee(ctx, intent)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,13 +41,13 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc
|
||||
if shouldRequestFX(intent) {
|
||||
fxQuote, err = s.requestFXQuote(ctx, orgRef, req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
return nil, time.Time{}, err
|
||||
}
|
||||
}
|
||||
|
||||
debitAmount, settlementAmount := computeAggregates(baseAmount, feeTotal, networkFee)
|
||||
|
||||
return &orchestratorv1.PaymentQuote{
|
||||
quote := &orchestratorv1.PaymentQuote{
|
||||
DebitAmount: debitAmount,
|
||||
ExpectedSettlementAmount: settlementAmount,
|
||||
ExpectedFeeTotal: feeTotal,
|
||||
@@ -56,7 +56,11 @@ func (s *Service) buildPaymentQuote(ctx context.Context, orgRef string, req *orc
|
||||
FxQuote: fxQuote,
|
||||
NetworkFee: networkFee,
|
||||
FeeQuoteToken: feeQuote.GetFeeQuoteToken(),
|
||||
}, nil
|
||||
}
|
||||
|
||||
expiresAt := quoteExpiry(s.clock.Now(), feeQuote, fxQuote)
|
||||
|
||||
return quote, expiresAt, nil
|
||||
}
|
||||
|
||||
func (s *Service) quoteFees(ctx context.Context, orgRef string, req *orchestratorv1.QuotePaymentRequest) (*feesv1.PrecomputeFeesResponse, error) {
|
||||
|
||||
@@ -2,6 +2,7 @@ package orchestrator
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/shopspring/decimal"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
@@ -219,6 +220,23 @@ func ledgerLineTypeFromAccounting(lineType accountingv1.PostingLineType) ledgerv
|
||||
}
|
||||
}
|
||||
|
||||
func quoteExpiry(now time.Time, feeQuote *feesv1.PrecomputeFeesResponse, fxQuote *oraclev1.Quote) time.Time {
|
||||
expiry := time.Time{}
|
||||
if feeQuote != nil && feeQuote.GetExpiresAt() != nil {
|
||||
expiry = feeQuote.GetExpiresAt().AsTime()
|
||||
}
|
||||
if expiry.IsZero() {
|
||||
expiry = now.Add(time.Duration(defaultFeeQuoteTTLMillis) * time.Millisecond)
|
||||
}
|
||||
if fxQuote != nil && fxQuote.GetExpiresAtUnixMs() > 0 {
|
||||
fxExpiry := time.UnixMilli(fxQuote.GetExpiresAtUnixMs()).UTC()
|
||||
if fxExpiry.Before(expiry) {
|
||||
expiry = fxExpiry
|
||||
}
|
||||
}
|
||||
return expiry
|
||||
}
|
||||
|
||||
func feeBreakdownFromQuote(quote *orchestratorv1.PaymentQuote) []*chainv1.ServiceFeeBreakdown {
|
||||
if quote == nil {
|
||||
return nil
|
||||
|
||||
@@ -3,8 +3,8 @@ package orchestrator
|
||||
import (
|
||||
"time"
|
||||
|
||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||
oracleclient "github.com/tech/sendico/fx/oracle/client"
|
||||
chainclient "github.com/tech/sendico/gateway/chain/client"
|
||||
ledgerclient "github.com/tech/sendico/ledger/client"
|
||||
clockpkg "github.com/tech/sendico/pkg/clock"
|
||||
feesv1 "github.com/tech/sendico/pkg/proto/billing/fees/v1"
|
||||
|
||||
@@ -16,6 +16,7 @@ import (
|
||||
orchestratorv1 "github.com/tech/sendico/pkg/proto/payments/orchestrator/v1"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"google.golang.org/grpc"
|
||||
"google.golang.org/protobuf/proto"
|
||||
)
|
||||
|
||||
type serviceError string
|
||||
@@ -132,6 +133,10 @@ func (s *Service) quotePaymentHandler(ctx context.Context, req *orchestratorv1.Q
|
||||
if orgRef == "" {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref is required"))
|
||||
}
|
||||
orgObjectID, parseErr := primitive.ObjectIDFromHex(orgRef)
|
||||
if parseErr != nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("organization_ref must be a valid objectID"))
|
||||
}
|
||||
intent := req.GetIntent()
|
||||
if intent == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent is required"))
|
||||
@@ -140,11 +145,31 @@ func (s *Service) quotePaymentHandler(ctx context.Context, req *orchestratorv1.Q
|
||||
return gsresponse.InvalidArgument[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("intent.amount is required"))
|
||||
}
|
||||
|
||||
quote, err := s.buildPaymentQuote(ctx, orgRef, req)
|
||||
quote, expiresAt, err := s.buildPaymentQuote(ctx, orgRef, req)
|
||||
if err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
if !req.GetPreviewOnly() {
|
||||
quotesStore := s.storage.Quotes()
|
||||
if quotesStore == nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
|
||||
}
|
||||
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(orgObjectID)
|
||||
if err := quotesStore.Create(ctx, record); err != nil {
|
||||
return gsresponse.Auto[orchestratorv1.QuotePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
}
|
||||
|
||||
return gsresponse.Success(&orchestratorv1.QuotePaymentResponse{Quote: quote})
|
||||
}
|
||||
|
||||
@@ -194,10 +219,34 @@ func (s *Service) initiatePaymentHandler(ctx context.Context, req *orchestratorv
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
|
||||
quote := req.GetFeeQuoteToken()
|
||||
quoteRef := strings.TrimSpace(req.GetQuoteRef())
|
||||
quote := strings.TrimSpace(req.GetFeeQuoteToken())
|
||||
var quoteSnapshot *orchestratorv1.PaymentQuote
|
||||
if quote == "" {
|
||||
quoteSnapshot, err = s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
|
||||
if quoteRef != "" {
|
||||
quotesStore := s.storage.Quotes()
|
||||
if quotesStore == nil {
|
||||
return gsresponse.Unavailable[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, errStorageUnavailable)
|
||||
}
|
||||
record, err := quotesStore.GetByRef(ctx, orgObjectID, quoteRef)
|
||||
if err != nil {
|
||||
if err == storage.ErrQuoteNotFound {
|
||||
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_not_found", merrors.InvalidArgument("quote_ref not found or expired"))
|
||||
}
|
||||
return gsresponse.Auto[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, err)
|
||||
}
|
||||
if !record.ExpiresAt.IsZero() && s.clock.Now().After(record.ExpiresAt) {
|
||||
return gsresponse.FailedPrecondition[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, "quote_expired", merrors.InvalidArgument("quote_ref expired"))
|
||||
}
|
||||
if !proto.Equal(protoIntentFromModel(record.Intent), intent) {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("quote_ref does not match intent"))
|
||||
}
|
||||
quoteSnapshot = modelQuoteToProto(record.Quote)
|
||||
if quoteSnapshot == nil {
|
||||
return gsresponse.InvalidArgument[orchestratorv1.InitiatePaymentResponse](s.logger, mservice.PaymentOrchestrator, merrors.InvalidArgument("stored quote is empty"))
|
||||
}
|
||||
quoteSnapshot.QuoteRef = quoteRef
|
||||
} else if quote == "" {
|
||||
quoteSnapshot, _, err = s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: req.GetMeta(),
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
Intent: req.GetIntent(),
|
||||
@@ -389,7 +438,7 @@ func (s *Service) initiateConversionHandler(ctx context.Context, req *orchestrat
|
||||
FeePolicy: req.GetFeePolicy(),
|
||||
}
|
||||
|
||||
quote, err := s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
|
||||
quote, _, err := s.buildPaymentQuote(ctx, orgRef, &orchestratorv1.QuotePaymentRequest{
|
||||
Meta: req.GetMeta(),
|
||||
IdempotencyKey: req.GetIdempotencyKey(),
|
||||
Intent: intentProto,
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/pkg/api/routers/gsresponse"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
mo "github.com/tech/sendico/pkg/model"
|
||||
moneyv1 "github.com/tech/sendico/pkg/proto/common/money/v1"
|
||||
chainv1 "github.com/tech/sendico/pkg/proto/gateway/chain/v1"
|
||||
@@ -208,11 +209,43 @@ func TestProcessDepositObservedHandler_MatchesPayment(t *testing.T) {
|
||||
// ----------------------------------------------------------------------
|
||||
|
||||
type stubRepository struct {
|
||||
store *stubPaymentsStore
|
||||
store *stubPaymentsStore
|
||||
quotes storage.QuotesStore
|
||||
}
|
||||
|
||||
func (r *stubRepository) Ping(context.Context) error { return nil }
|
||||
func (r *stubRepository) Payments() storage.PaymentsStore { return r.store }
|
||||
func (r *stubRepository) Quotes() storage.QuotesStore {
|
||||
if r.quotes != nil {
|
||||
return r.quotes
|
||||
}
|
||||
return &stubQuotesStore{}
|
||||
}
|
||||
|
||||
type stubQuotesStore struct {
|
||||
quotes map[string]*model.PaymentQuoteRecord
|
||||
}
|
||||
|
||||
func (s *stubQuotesStore) Create(ctx context.Context, quote *model.PaymentQuoteRecord) error {
|
||||
if quote == nil {
|
||||
return merrors.InvalidArgument("nil quote")
|
||||
}
|
||||
if s.quotes == nil {
|
||||
s.quotes = map[string]*model.PaymentQuoteRecord{}
|
||||
}
|
||||
s.quotes[strings.TrimSpace(quote.QuoteRef)] = quote
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *stubQuotesStore) GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
|
||||
if s.quotes == nil {
|
||||
return nil, storage.ErrQuoteNotFound
|
||||
}
|
||||
if q, ok := s.quotes[strings.TrimSpace(quoteRef)]; ok {
|
||||
return q, nil
|
||||
}
|
||||
return nil, storage.ErrQuoteNotFound
|
||||
}
|
||||
|
||||
type stubPaymentsStore struct {
|
||||
payments map[string]*model.Payment
|
||||
|
||||
@@ -119,6 +119,7 @@ type PaymentQuoteSnapshot struct {
|
||||
FXQuote *oraclev1.Quote `bson:"fxQuote,omitempty" json:"fxQuote,omitempty"`
|
||||
NetworkFee *chainv1.EstimateTransferFeeResponse `bson:"networkFee,omitempty" json:"networkFee,omitempty"`
|
||||
FeeQuoteToken string `bson:"feeQuoteToken,omitempty" json:"feeQuoteToken,omitempty"`
|
||||
QuoteRef string `bson:"quoteRef,omitempty" json:"quoteRef,omitempty"`
|
||||
}
|
||||
|
||||
// ExecutionRefs links to downstream systems.
|
||||
|
||||
24
api/payments/orchestrator/storage/model/quote.go
Normal file
24
api/payments/orchestrator/storage/model/quote.go
Normal file
@@ -0,0 +1,24 @@
|
||||
package model
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/pkg/db/storable"
|
||||
"github.com/tech/sendico/pkg/model"
|
||||
)
|
||||
|
||||
// PaymentQuoteRecord stores a quoted payment snapshot for later execution.
|
||||
type PaymentQuoteRecord struct {
|
||||
storable.Base `bson:",inline" json:",inline"`
|
||||
model.OrganizationBoundBase `bson:",inline" json:",inline"`
|
||||
|
||||
QuoteRef string `bson:"quoteRef" json:"quoteRef"`
|
||||
Intent PaymentIntent `bson:"intent" json:"intent"`
|
||||
Quote *PaymentQuoteSnapshot `bson:"quote" json:"quote"`
|
||||
ExpiresAt time.Time `bson:"expiresAt" json:"expiresAt"`
|
||||
}
|
||||
|
||||
// Collection implements storable.Storable.
|
||||
func (*PaymentQuoteRecord) Collection() string {
|
||||
return "payment_quotes"
|
||||
}
|
||||
@@ -18,6 +18,7 @@ type Store struct {
|
||||
ping func(context.Context) error
|
||||
|
||||
payments storage.PaymentsStore
|
||||
quotes storage.QuotesStore
|
||||
}
|
||||
|
||||
// New constructs a Mongo-backed payments repository from a Mongo connection.
|
||||
@@ -25,28 +26,37 @@ func New(logger mlogger.Logger, conn *db.MongoConnection) (*Store, error) {
|
||||
if conn == nil {
|
||||
return nil, merrors.InvalidArgument("payments.storage.mongo: connection is nil")
|
||||
}
|
||||
repo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection())
|
||||
return NewWithRepository(logger, conn.Ping, repo)
|
||||
paymentsRepo := repository.CreateMongoRepository(conn.Database(), (&model.Payment{}).Collection())
|
||||
quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection())
|
||||
return NewWithRepository(logger, conn.Ping, paymentsRepo, quotesRepo)
|
||||
}
|
||||
|
||||
// NewWithRepository constructs a payments repository using the provided primitives.
|
||||
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository) (*Store, error) {
|
||||
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, paymentsRepo repository.Repository, quotesRepo repository.Repository) (*Store, error) {
|
||||
if ping == nil {
|
||||
return nil, merrors.InvalidArgument("payments.storage.mongo: ping func is nil")
|
||||
}
|
||||
if paymentsRepo == nil {
|
||||
return nil, merrors.InvalidArgument("payments.storage.mongo: payments repository is nil")
|
||||
}
|
||||
if quotesRepo == nil {
|
||||
return nil, merrors.InvalidArgument("payments.storage.mongo: quotes repository is nil")
|
||||
}
|
||||
|
||||
childLogger := logger.Named("storage").Named("mongo")
|
||||
paymentsStore, err := store.NewPayments(childLogger, paymentsRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
quotesStore, err := store.NewQuotes(childLogger, quotesRepo)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &Store{
|
||||
logger: childLogger,
|
||||
ping: ping,
|
||||
payments: paymentsStore,
|
||||
quotes: quotesStore,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
@@ -65,4 +75,9 @@ func (s *Store) Payments() storage.PaymentsStore {
|
||||
return s.payments
|
||||
}
|
||||
|
||||
// Quotes returns the quotes store.
|
||||
func (s *Store) Quotes() storage.QuotesStore {
|
||||
return s.quotes
|
||||
}
|
||||
|
||||
var _ storage.Repository = (*Store)(nil)
|
||||
|
||||
113
api/payments/orchestrator/storage/mongo/store/quotes.go
Normal file
113
api/payments/orchestrator/storage/mongo/store/quotes.go
Normal file
@@ -0,0 +1,113 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/orchestrator/storage"
|
||||
"github.com/tech/sendico/payments/orchestrator/storage/model"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"go.mongodb.org/mongo-driver/bson/primitive"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Quotes struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
}
|
||||
|
||||
// NewQuotes constructs a Mongo-backed quotes store.
|
||||
func NewQuotes(logger mlogger.Logger, repo repository.Repository) (*Quotes, error) {
|
||||
if repo == nil {
|
||||
return nil, merrors.InvalidArgument("quotesStore: repository is nil")
|
||||
}
|
||||
|
||||
indexes := []*ri.Definition{
|
||||
{
|
||||
Keys: []ri.Key{{Field: "quoteRef", Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}},
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "expiresAt", Sort: ri.Asc}},
|
||||
ExpireAfterSeconds: 0,
|
||||
},
|
||||
}
|
||||
|
||||
for _, def := range indexes {
|
||||
if err := repo.CreateIndex(def); err != nil {
|
||||
logger.Error("failed to ensure quotes index", zap.Error(err), zap.String("collection", repo.Collection()))
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
return &Quotes{
|
||||
logger: logger.Named("quotes"),
|
||||
repo: repo,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (q *Quotes) Create(ctx context.Context, quote *model.PaymentQuoteRecord) error {
|
||||
if quote == nil {
|
||||
return merrors.InvalidArgument("quotesStore: nil quote")
|
||||
}
|
||||
quote.QuoteRef = strings.TrimSpace(quote.QuoteRef)
|
||||
if quote.QuoteRef == "" {
|
||||
return merrors.InvalidArgument("quotesStore: empty quoteRef")
|
||||
}
|
||||
if quote.OrganizationRef == primitive.NilObjectID {
|
||||
return merrors.InvalidArgument("quotesStore: organization_ref is required")
|
||||
}
|
||||
if quote.ExpiresAt.IsZero() {
|
||||
return merrors.InvalidArgument("quotesStore: expires_at is required")
|
||||
}
|
||||
if quote.Intent.Attributes != nil {
|
||||
for k, v := range quote.Intent.Attributes {
|
||||
quote.Intent.Attributes[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
quote.Update()
|
||||
|
||||
filter := repository.OrgFilter(quote.OrganizationRef).And(
|
||||
repository.Filter("quoteRef", quote.QuoteRef),
|
||||
)
|
||||
|
||||
if err := q.repo.Insert(ctx, quote, filter); err != nil {
|
||||
if errors.Is(err, merrors.ErrDataConflict) {
|
||||
return storage.ErrDuplicateQuote
|
||||
}
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *Quotes) GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
|
||||
quoteRef = strings.TrimSpace(quoteRef)
|
||||
if quoteRef == "" {
|
||||
return nil, merrors.InvalidArgument("quotesStore: empty quoteRef")
|
||||
}
|
||||
if orgRef == primitive.NilObjectID {
|
||||
return nil, merrors.InvalidArgument("quotesStore: organization_ref is required")
|
||||
}
|
||||
entity := &model.PaymentQuoteRecord{}
|
||||
query := repository.OrgFilter(orgRef).And(repository.Filter("quoteRef", quoteRef))
|
||||
if err := q.repo.FindOneByFilter(ctx, query, entity); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
return nil, storage.ErrQuoteNotFound
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
if !entity.ExpiresAt.IsZero() && time.Now().After(entity.ExpiresAt) {
|
||||
return nil, storage.ErrQuoteNotFound
|
||||
}
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
var _ storage.QuotesStore = (*Quotes)(nil)
|
||||
@@ -18,12 +18,17 @@ var (
|
||||
ErrPaymentNotFound = storageError("payments.orchestrator.storage: payment not found")
|
||||
// ErrDuplicatePayment signals that idempotency constraints were violated.
|
||||
ErrDuplicatePayment = storageError("payments.orchestrator.storage: duplicate payment")
|
||||
// ErrQuoteNotFound signals that a stored quote does not exist or expired.
|
||||
ErrQuoteNotFound = storageError("payments.orchestrator.storage: quote not found")
|
||||
// ErrDuplicateQuote signals that a quote reference already exists.
|
||||
ErrDuplicateQuote = storageError("payments.orchestrator.storage: duplicate quote")
|
||||
)
|
||||
|
||||
// Repository exposes persistence primitives for the orchestrator domain.
|
||||
type Repository interface {
|
||||
Ping(ctx context.Context) error
|
||||
Payments() PaymentsStore
|
||||
Quotes() QuotesStore
|
||||
}
|
||||
|
||||
// PaymentsStore manages payment lifecycle state.
|
||||
@@ -35,3 +40,9 @@ type PaymentsStore interface {
|
||||
GetByChainTransferRef(ctx context.Context, transferRef string) (*model.Payment, error)
|
||||
List(ctx context.Context, filter *model.PaymentFilter) (*model.PaymentList, error)
|
||||
}
|
||||
|
||||
// QuotesStore manages temporary stored payment quotes.
|
||||
type QuotesStore interface {
|
||||
Create(ctx context.Context, quote *model.PaymentQuoteRecord) error
|
||||
GetByRef(ctx context.Context, orgRef primitive.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user