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 }