Files
sendico/api/payments/storage/quote/mongo/store/quotes.go
2026-02-26 02:39:48 +01:00

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
}