201 lines
7.2 KiB
Go
201 lines
7.2 KiB
Go
package store
|
|
|
|
import (
|
|
"context"
|
|
"errors"
|
|
"strconv"
|
|
"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")
|
|
}
|
|
quote.RequestShape = model.QuoteRequestShape(strings.TrimSpace(string(quote.RequestShape)))
|
|
if quote.RequestShape == "" || quote.RequestShape == model.QuoteRequestShapeUnspecified {
|
|
return merrors.InvalidArgument("quotesStore: request shape is required")
|
|
}
|
|
if len(quote.Items) == 0 {
|
|
return merrors.InvalidArgument("quotesStore: items are required")
|
|
}
|
|
if quote.RequestShape == model.QuoteRequestShapeSingle && len(quote.Items) != 1 {
|
|
return merrors.InvalidArgument("quotesStore: single shape requires exactly one item")
|
|
}
|
|
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)
|
|
}
|
|
for i := range quote.Items {
|
|
item := quote.Items[i]
|
|
if item == nil {
|
|
return merrors.InvalidArgument("quotesStore: items[" + strconv.Itoa(i) + "] is required")
|
|
}
|
|
if item.Intent == nil {
|
|
return merrors.InvalidArgument("quotesStore: items[" + strconv.Itoa(i) + "].intent is required")
|
|
}
|
|
if item.Quote == nil {
|
|
return merrors.InvalidArgument("quotesStore: items[" + strconv.Itoa(i) + "].quote is required")
|
|
}
|
|
if item.Status == nil {
|
|
return merrors.InvalidArgument("quotesStore: items[" + strconv.Itoa(i) + "].status is required")
|
|
}
|
|
if item.Intent.Attributes != nil {
|
|
for k, v := range item.Intent.Attributes {
|
|
item.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 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
|
|
}
|