separated quotation and payments
This commit is contained in:
90
api/payments/storage/quote/mongo/repository.go
Normal file
90
api/payments/storage/quote/mongo/repository.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package mongo
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"github.com/tech/sendico/payments/storage/quote/mongo/store"
|
||||
"github.com/tech/sendico/pkg/db"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
)
|
||||
|
||||
// Store implements quotestorage.Repository backed by MongoDB.
|
||||
type Store struct {
|
||||
logger mlogger.Logger
|
||||
ping func(context.Context) error
|
||||
|
||||
quotes quotestorage.QuotesStore
|
||||
}
|
||||
|
||||
type options struct {
|
||||
quoteRetention time.Duration
|
||||
}
|
||||
|
||||
// Option configures the Mongo-backed quotes repository.
|
||||
type Option func(*options)
|
||||
|
||||
// WithQuoteRetention sets how long quote records are retained after expiry.
|
||||
func WithQuoteRetention(retention time.Duration) Option {
|
||||
return func(opts *options) {
|
||||
opts.quoteRetention = retention
|
||||
}
|
||||
}
|
||||
|
||||
// New constructs a Mongo-backed quotes repository from a Mongo connection.
|
||||
func New(logger mlogger.Logger, conn *db.MongoConnection, opts ...Option) (*Store, error) {
|
||||
if conn == nil {
|
||||
return nil, merrors.InvalidArgument("payments.quote.storage.mongo: connection is nil")
|
||||
}
|
||||
quotesRepo := repository.CreateMongoRepository(conn.Database(), (&model.PaymentQuoteRecord{}).Collection())
|
||||
return NewWithRepository(logger, conn.Ping, quotesRepo, opts...)
|
||||
}
|
||||
|
||||
// NewWithRepository constructs a quotes repository using the provided primitives.
|
||||
func NewWithRepository(logger mlogger.Logger, ping func(context.Context) error, quotesRepo repository.Repository, opts ...Option) (*Store, error) {
|
||||
if ping == nil {
|
||||
return nil, merrors.InvalidArgument("payments.quote.storage.mongo: ping func is nil")
|
||||
}
|
||||
if quotesRepo == nil {
|
||||
return nil, merrors.InvalidArgument("payments.quote.storage.mongo: quotes repository is nil")
|
||||
}
|
||||
|
||||
cfg := options{}
|
||||
for _, opt := range opts {
|
||||
if opt != nil {
|
||||
opt(&cfg)
|
||||
}
|
||||
}
|
||||
|
||||
childLogger := logger.Named("quote_storage").Named("mongo")
|
||||
quotesStore, err := store.NewQuotes(childLogger, quotesRepo, cfg.quoteRetention)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result := &Store{
|
||||
logger: childLogger,
|
||||
ping: ping,
|
||||
quotes: quotesStore,
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// Ping verifies connectivity with the backing database.
|
||||
func (s *Store) Ping(ctx context.Context) error {
|
||||
if s.ping == nil {
|
||||
return merrors.InvalidArgument("payments.quote.storage.mongo: ping func is nil")
|
||||
}
|
||||
return s.ping(ctx)
|
||||
}
|
||||
|
||||
// Quotes returns the quotes store.
|
||||
func (s *Store) Quotes() quotestorage.QuotesStore {
|
||||
return s.quotes
|
||||
}
|
||||
|
||||
var _ quotestorage.Repository = (*Store)(nil)
|
||||
184
api/payments/storage/quote/mongo/store/quotes.go
Normal file
184
api/payments/storage/quote/mongo/store/quotes.go
Normal file
@@ -0,0 +1,184 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
quotestorage "github.com/tech/sendico/payments/storage/quote"
|
||||
"github.com/tech/sendico/pkg/db/repository"
|
||||
"github.com/tech/sendico/pkg/db/repository/builder"
|
||||
ri "github.com/tech/sendico/pkg/db/repository/index"
|
||||
"github.com/tech/sendico/pkg/merrors"
|
||||
"github.com/tech/sendico/pkg/mlogger"
|
||||
"github.com/tech/sendico/pkg/mutil/mzap"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
type Quotes struct {
|
||||
logger mlogger.Logger
|
||||
repo repository.Repository
|
||||
retention time.Duration
|
||||
}
|
||||
|
||||
const defaultPaymentQuoteRetention = 72 * time.Hour
|
||||
|
||||
// NewQuotes constructs a Mongo-backed quotes store.
|
||||
func NewQuotes(logger mlogger.Logger, repo repository.Repository, retention time.Duration) (*Quotes, error) {
|
||||
if repo == nil {
|
||||
return nil, merrors.InvalidArgument("quotesStore: repository is nil")
|
||||
}
|
||||
if retention <= 0 {
|
||||
logger.Info("Using default retention duration", zap.Duration("default_retention", defaultPaymentQuoteRetention))
|
||||
retention = defaultPaymentQuoteRetention
|
||||
}
|
||||
logger.Info("Using retention duration", zap.Duration("retention", retention))
|
||||
|
||||
indexes := []*ri.Definition{
|
||||
{
|
||||
Keys: []ri.Key{{Field: "quoteRef", Sort: ri.Asc}},
|
||||
Unique: true,
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{
|
||||
{Field: "organizationRef", Sort: ri.Asc},
|
||||
{Field: "idempotencyKey", Sort: ri.Asc},
|
||||
},
|
||||
Unique: true,
|
||||
Name: "payment_quotes_org_idempotency_key",
|
||||
PartialFilter: repository.Query().Comparison(repository.Field("idempotencyKey"), builder.Exists, true),
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "organizationRef", Sort: ri.Asc}},
|
||||
},
|
||||
{
|
||||
Keys: []ri.Key{{Field: "purgeAt", Sort: ri.Asc}},
|
||||
TTL: int32Ptr(0),
|
||||
Name: "payment_quotes_purge_at_ttl",
|
||||
},
|
||||
}
|
||||
|
||||
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,
|
||||
retention: retention,
|
||||
}, 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 == bson.NilObjectID {
|
||||
return merrors.InvalidArgument("quotesStore: organization_ref is required")
|
||||
}
|
||||
quote.IdempotencyKey = strings.TrimSpace(quote.IdempotencyKey)
|
||||
if quote.IdempotencyKey == "" {
|
||||
return merrors.InvalidArgument("quotesStore: idempotency key is required")
|
||||
}
|
||||
if quote.ExpiresAt.IsZero() {
|
||||
return merrors.InvalidArgument("quotesStore: expires_at is required")
|
||||
}
|
||||
if quote.PurgeAt.IsZero() || quote.PurgeAt.Before(quote.ExpiresAt) {
|
||||
quote.PurgeAt = quote.ExpiresAt.Add(q.retention)
|
||||
}
|
||||
if quote.Intent.Attributes != nil {
|
||||
for k, v := range quote.Intent.Attributes {
|
||||
quote.Intent.Attributes[k] = strings.TrimSpace(v)
|
||||
}
|
||||
}
|
||||
if len(quote.Intents) > 0 {
|
||||
for i := range quote.Intents {
|
||||
if quote.Intents[i].Attributes == nil {
|
||||
continue
|
||||
}
|
||||
for k, v := range quote.Intents[i].Attributes {
|
||||
quote.Intents[i].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 quotestorage.ErrDuplicateQuote
|
||||
}
|
||||
q.logger.Warn("Failed to insert quote", mzap.ObjRef("org_ref", quote.OrganizationRef), zap.String("quote_ref", quote.QuoteRef), zap.Error(err))
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (q *Quotes) GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error) {
|
||||
quoteRef = strings.TrimSpace(quoteRef)
|
||||
if quoteRef == "" {
|
||||
return nil, merrors.InvalidArgument("quotesStore: empty quoteRef")
|
||||
}
|
||||
if orgRef == bson.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) {
|
||||
q.logger.Debug("Quote not found by ref", zap.String("quote_ref", quoteRef), mzap.ObjRef("org_ref", orgRef))
|
||||
return nil, quotestorage.ErrQuoteNotFound
|
||||
}
|
||||
q.logger.Warn("Failed to fetch quote by ref", zap.String("quote_ref", quoteRef), mzap.ObjRef("org_ref", orgRef), zap.Error(err))
|
||||
return nil, err
|
||||
}
|
||||
if !entity.ExpiresAt.IsZero() && time.Now().After(entity.ExpiresAt) {
|
||||
q.logger.Debug("Quote expired by idempotency key", zap.String("quote_ref", quoteRef), mzap.ObjRef("org_ref", orgRef), zap.Time("expires_at", entity.ExpiresAt))
|
||||
return nil, quotestorage.ErrQuoteNotFound
|
||||
}
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
func (q *Quotes) GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error) {
|
||||
idempotencyKey = strings.TrimSpace(idempotencyKey)
|
||||
if idempotencyKey == "" {
|
||||
return nil, merrors.InvalidArgument("quotesStore: empty idempotency key")
|
||||
}
|
||||
if orgRef == bson.NilObjectID {
|
||||
return nil, merrors.InvalidArgument("quotesStore: organization_ref is required")
|
||||
}
|
||||
entity := &model.PaymentQuoteRecord{}
|
||||
query := repository.OrgFilter(orgRef).And(repository.Filter("idempotencyKey", idempotencyKey))
|
||||
if err := q.repo.FindOneByFilter(ctx, query, entity); err != nil {
|
||||
if errors.Is(err, merrors.ErrNoData) {
|
||||
q.logger.Debug("Quote not found by idempotency key", zap.String("idempotency_key", idempotencyKey), mzap.ObjRef("org_ref", orgRef))
|
||||
return nil, quotestorage.ErrQuoteNotFound
|
||||
}
|
||||
q.logger.Warn("Failed to fetch quoteby idempotency key", zap.String("idempotency_key", idempotencyKey), mzap.ObjRef("org_ref", orgRef))
|
||||
return nil, err
|
||||
}
|
||||
if !entity.ExpiresAt.IsZero() && time.Now().After(entity.ExpiresAt) {
|
||||
q.logger.Debug("Quote expired by idempotency key", zap.String("idempotency_key", idempotencyKey), mzap.ObjRef("org_ref", orgRef), zap.Time("expires_at", entity.ExpiresAt))
|
||||
return nil, quotestorage.ErrQuoteNotFound
|
||||
}
|
||||
return entity, nil
|
||||
}
|
||||
|
||||
var _ quotestorage.QuotesStore = (*Quotes)(nil)
|
||||
|
||||
func int32Ptr(v int32) *int32 {
|
||||
return &v
|
||||
}
|
||||
34
api/payments/storage/quote/storage.go
Normal file
34
api/payments/storage/quote/storage.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package storage
|
||||
|
||||
import (
|
||||
"context"
|
||||
|
||||
"github.com/tech/sendico/payments/storage/model"
|
||||
"go.mongodb.org/mongo-driver/v2/bson"
|
||||
)
|
||||
|
||||
type storageError string
|
||||
|
||||
func (e storageError) Error() string {
|
||||
return string(e)
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrQuoteNotFound signals that a stored quote does not exist or expired.
|
||||
ErrQuoteNotFound = storageError("payments.storage.quote: quote not found")
|
||||
// ErrDuplicateQuote signals that a quote reference already exists.
|
||||
ErrDuplicateQuote = storageError("payments.storage.quote: duplicate quote")
|
||||
)
|
||||
|
||||
// Repository exposes persistence primitives for quote records.
|
||||
type Repository interface {
|
||||
Ping(ctx context.Context) error
|
||||
Quotes() QuotesStore
|
||||
}
|
||||
|
||||
// QuotesStore manages temporary stored payment quotes.
|
||||
type QuotesStore interface {
|
||||
Create(ctx context.Context, quote *model.PaymentQuoteRecord) error
|
||||
GetByRef(ctx context.Context, orgRef bson.ObjectID, quoteRef string) (*model.PaymentQuoteRecord, error)
|
||||
GetByIdempotencyKey(ctx context.Context, orgRef bson.ObjectID, idempotencyKey string) (*model.PaymentQuoteRecord, error)
|
||||
}
|
||||
Reference in New Issue
Block a user