package store import ( "context" "errors" "fmt" "sort" "strings" "time" "github.com/tech/sendico/billing/fees/storage" "github.com/tech/sendico/billing/fees/storage/model" dmath "github.com/tech/sendico/pkg/decimal" "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" m "github.com/tech/sendico/pkg/model" "github.com/tech/sendico/pkg/mservice" "go.mongodb.org/mongo-driver/bson/primitive" "go.mongodb.org/mongo-driver/mongo" "go.uber.org/zap" ) type plansStore struct { logger mlogger.Logger repo repository.Repository } // NewPlans constructs a Mongo-backed PlansStore. func NewPlans(logger mlogger.Logger, db *mongo.Database) (storage.PlansStore, error) { repo := repository.CreateMongoRepository(db, mservice.FeePlans) // Index for organisation lookups. orgIndex := &ri.Definition{ Keys: []ri.Key{ {Field: m.OrganizationRefField, Sort: ri.Asc}, {Field: "effectiveFrom", Sort: ri.Desc}, }, } if err := repo.CreateIndex(orgIndex); err != nil { logger.Error("failed to ensure fee plan organization index", zap.Error(err)) return nil, err } // Unique index for plan versions (per organisation + effectiveFrom). uniqueIndex := &ri.Definition{ Keys: []ri.Key{ {Field: m.OrganizationRefField, Sort: ri.Asc}, {Field: "effectiveFrom", Sort: ri.Asc}, }, Unique: true, } if err := repo.CreateIndex(uniqueIndex); err != nil { logger.Error("failed to ensure fee plan uniqueness index", zap.Error(err)) return nil, err } // Recommended index to speed up active-plan lookups (org/global + active + dates). activeIndex := &ri.Definition{ Keys: []ri.Key{ {Field: m.OrganizationRefField, Sort: ri.Asc}, {Field: "active", Sort: ri.Asc}, {Field: "effectiveFrom", Sort: ri.Asc}, {Field: "effectiveTo", Sort: ri.Asc}, }, } if err := repo.CreateIndex(activeIndex); err != nil { logger.Warn("failed to ensure fee plan active index", zap.Error(err)) } return &plansStore{ logger: logger.Named("plans"), repo: repo, }, nil } func (p *plansStore) Create(ctx context.Context, plan *model.FeePlan) error { if err := validatePlan(plan); err != nil { return err } if err := p.ensureNoOverlap(ctx, plan); err != nil { return err } if err := p.repo.Insert(ctx, plan, nil); err != nil { if errors.Is(err, merrors.ErrDataConflict) { return storage.ErrDuplicateFeePlan } p.logger.Warn("failed to create fee plan", zap.Error(err)) return err } return nil } func (p *plansStore) Update(ctx context.Context, plan *model.FeePlan) error { if plan == nil || plan.GetID() == nil || plan.GetID().IsZero() { return merrors.InvalidArgument("plansStore: invalid fee plan reference") } if err := validatePlan(plan); err != nil { return err } if err := p.ensureNoOverlap(ctx, plan); err != nil { return err } if err := p.repo.Update(ctx, plan); err != nil { p.logger.Warn("failed to update fee plan", zap.Error(err)) return err } return nil } func (p *plansStore) Get(ctx context.Context, planRef primitive.ObjectID) (*model.FeePlan, error) { if planRef.IsZero() { return nil, merrors.InvalidArgument("plansStore: zero plan reference") } result := &model.FeePlan{} if err := p.repo.Get(ctx, planRef, result); err != nil { if errors.Is(err, merrors.ErrNoData) { return nil, storage.ErrFeePlanNotFound } return nil, err } return result, nil } func (p *plansStore) GetActivePlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) { // Compatibility shim: prefer org plan, fall back to global; allow zero org to mean global. if orgRef.IsZero() { return p.FindActiveGlobalPlan(ctx, at) } plan, err := p.FindActiveOrgPlan(ctx, orgRef, at) if err == nil { return plan, nil } if errors.Is(err, storage.ErrFeePlanNotFound) { return p.FindActiveGlobalPlan(ctx, at) } return nil, err } func (p *plansStore) FindActiveOrgPlan(ctx context.Context, orgRef primitive.ObjectID, at time.Time) (*model.FeePlan, error) { if orgRef.IsZero() { return nil, merrors.InvalidArgument("plansStore: zero organization reference") } query := repository.Query().Filter(repository.OrgField(), orgRef) return p.findActivePlan(ctx, query, at) } func (p *plansStore) FindActiveGlobalPlan(ctx context.Context, at time.Time) (*model.FeePlan, error) { globalQuery := repository.Query().Or( repository.Exists(repository.OrgField(), false), repository.Query().Filter(repository.OrgField(), nil), ) return p.findActivePlan(ctx, globalQuery, at) } var _ storage.PlansStore = (*plansStore)(nil) func (p *plansStore) findActivePlan(ctx context.Context, orgQuery builder.Query, at time.Time) (*model.FeePlan, error) { limit := int64(2) query := orgQuery. Filter(repository.Field("active"), true). Comparison(repository.Field("effectiveFrom"), builder.Lte, at). Sort(repository.Field("effectiveFrom"), false). Limit(&limit) query = query.And( repository.Query().Or( repository.Query().Filter(repository.Field("effectiveTo"), nil), repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, at), ), ) var plans []*model.FeePlan decoder := func(cursor *mongo.Cursor) error { target := &model.FeePlan{} if err := cursor.Decode(target); err != nil { return err } plans = append(plans, target) return nil } if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil { if errors.Is(err, merrors.ErrNoData) { return nil, storage.ErrFeePlanNotFound } return nil, err } if len(plans) == 0 { return nil, storage.ErrFeePlanNotFound } if len(plans) > 1 { return nil, storage.ErrConflictingFeePlans } return plans[0], nil } func validatePlan(plan *model.FeePlan) error { if plan == nil { return merrors.InvalidArgument("plansStore: nil fee plan") } if len(plan.Rules) == 0 { return merrors.InvalidArgument("plansStore: fee plan must contain at least one rule") } if plan.Active && plan.EffectiveTo != nil && plan.EffectiveTo.Before(plan.EffectiveFrom) { return merrors.InvalidArgument("plansStore: effectiveTo cannot be before effectiveFrom") } // Ensure unique priority per (trigger, appliesTo) combination. seen := make(map[string]struct{}) for _, rule := range plan.Rules { if strings.TrimSpace(rule.Percentage) != "" { if _, err := dmath.RatFromString(rule.Percentage); err != nil { return merrors.InvalidArgument("plansStore: invalid rule percentage") } } if strings.TrimSpace(rule.FixedAmount) != "" { if _, err := dmath.RatFromString(rule.FixedAmount); err != nil { return merrors.InvalidArgument("plansStore: invalid rule fixed amount") } } if strings.TrimSpace(rule.MinimumAmount) != "" { if _, err := dmath.RatFromString(rule.MinimumAmount); err != nil { return merrors.InvalidArgument("plansStore: invalid rule minimum amount") } } if strings.TrimSpace(rule.MaximumAmount) != "" { if _, err := dmath.RatFromString(rule.MaximumAmount); err != nil { return merrors.InvalidArgument("plansStore: invalid rule maximum amount") } } appliesKey := normalizeAppliesTo(rule.AppliesTo) priorityKey := fmt.Sprintf("%s|%d|%s", rule.Trigger, rule.Priority, appliesKey) if _, ok := seen[priorityKey]; ok { return merrors.InvalidArgument("plansStore: duplicate priority for trigger/appliesTo") } seen[priorityKey] = struct{}{} } return nil } func normalizeAppliesTo(applies map[string]string) string { if len(applies) == 0 { return "" } keys := make([]string, 0, len(applies)) for k := range applies { keys = append(keys, k) } sort.Strings(keys) parts := make([]string, 0, len(keys)) for _, k := range keys { parts = append(parts, k+"="+applies[k]) } return strings.Join(parts, ",") } func (p *plansStore) ensureNoOverlap(ctx context.Context, plan *model.FeePlan) error { if plan == nil || !plan.Active { return nil } orgQuery := repository.Query() if plan.OrganizationRef.IsZero() { orgQuery = repository.Query().Or( repository.Exists(repository.OrgField(), false), repository.Query().Filter(repository.OrgField(), nil), ) } else { orgQuery = repository.Query().Filter(repository.OrgField(), plan.OrganizationRef) } maxTime := time.Date(9999, 12, 31, 23, 59, 59, 0, time.UTC) newFrom := plan.EffectiveFrom newTo := maxTime if plan.EffectiveTo != nil { newTo = *plan.EffectiveTo } query := orgQuery. Filter(repository.Field("active"), true). Comparison(repository.Field("effectiveFrom"), builder.Lte, newTo). And(repository.Query().Or( repository.Query().Filter(repository.Field("effectiveTo"), nil), repository.Query().Comparison(repository.Field("effectiveTo"), builder.Gte, newFrom), )) if id := plan.GetID(); id != nil && !id.IsZero() { query = query.And(repository.Query().Comparison(repository.IDField(), builder.Ne, *id)) } limit := int64(1) query = query.Limit(&limit) var overlapFound bool decoder := func(cursor *mongo.Cursor) error { overlapFound = true return nil } if err := p.repo.FindManyByFilter(ctx, query, decoder); err != nil { if errors.Is(err, merrors.ErrNoData) { return nil } return err } if overlapFound { return storage.ErrConflictingFeePlans } return nil }