// Copyright IBM Corp. 2020, 2026 // SPDX-License-Identifier: BUSL-1.1 package billing import ( "context" "database/sql" "time" "repo.anhonesthost.net/CyberCoveLLC/threshold/internal/db" "repo.anhonesthost.net/CyberCoveLLC/threshold/internal/db/timestamp" "repo.anhonesthost.net/CyberCoveLLC/threshold/internal/errors" ) // A Repository retrieves the persistent type in the billing // package. It is not safe to use a repository concurrently. // It provides a method for requesting pre-aggregated user counts // per month. Depending on whether a start time and/or end time are given, // an ActiveUsers object will be returned: // - for every month from the provided start date until the present date, // with the present date being a cumulative count up to the present date. // - for every month from the provided start date until the provided end date. // - for the previous month and current month, with the current month being a // cumulative count up to the present date. type Repository struct { reader db.Reader } // NewRepository creates a new Repository. The returned repository is not safe for concurrent go // routines to access it. func NewRepository(ctx context.Context, r db.Reader) (*Repository, error) { const op = "billing.NewRepository" switch { case r == nil: return nil, errors.New(ctx, errors.InvalidParameter, op, "nil db reader") } return &Repository{ reader: r, }, nil } // MonthlyActiveUsers returns the active users for a range of months, from most recent to least. // If no start or end time is provided, it will return the active users for the last two months. // If a start time is provided, it will return the active users for that month until the current month. // If both a start and end time are provided, it will return the active users for that time range, // starting time inclusive and ending time exclusive. // The times provided must be the start of the month at midnight UTC. func (r *Repository) MonthlyActiveUsers(ctx context.Context, opt ...Option) ([]ActiveUsers, error) { const op = "billing.Repository.MonthlyActiveUsers" opts := getOpts(opt...) switch { case opts.withEndTime != nil && opts.withStartTime == nil: return nil, errors.New(ctx, errors.InvalidParameter, op, "end time set without start time") case opts.withEndTime != nil && !opts.withEndTime.After(*opts.withStartTime): return nil, errors.New(ctx, errors.InvalidParameter, op, "start time is not before end time") } query := activeUsersLastTwoMonthsQuery var args []any if opts.withStartTime != nil { if *opts.withStartTime != time.Date(opts.withStartTime.Year(), opts.withStartTime.Month(), 1, 0, 0, 0, 0, time.UTC) { return nil, errors.New(ctx, errors.InvalidParameter, op, "start time must be the first day of the month at midnight UTC") } query = activeUsersWithStartTimeQuery args = append(args, sql.Named("start_time", timestamp.New(*opts.withStartTime))) } if opts.withEndTime != nil { if *opts.withEndTime != time.Date(opts.withEndTime.Year(), opts.withEndTime.Month(), 1, 0, 0, 0, 0, time.UTC) { return nil, errors.New(ctx, errors.InvalidParameter, op, "end time must be the first day of the month at midnight UTC") } query = activeUsersWithStartTimeAndEndTimeQuery args = append(args, sql.Named("end_time", timestamp.New(*opts.withEndTime))) } var activeUsers []ActiveUsers rows, err := r.reader.Query(ctx, query, args) if err != nil { return nil, err } defer rows.Close() if err := rows.Err(); err != nil { return nil, err } for rows.Next() { var startTime time.Time var endTime time.Time var count uint32 if err := rows.Scan(&startTime, &endTime, &count); err != nil { return nil, err } // set start and end times to be in UTC auUTC := ActiveUsers{ ActiveUsersCount: count, StartTime: startTime.UTC(), EndTime: endTime.UTC(), } activeUsers = append(activeUsers, auUTC) } return activeUsers, nil }