Path: blob/main/components/gitpod-db/go/cost_center.go
2497 views
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License.AGPL.txt in the project root for license information.34package db56import (7"context"8"errors"9"fmt"10"strings"11"time"1213"github.com/gitpod-io/gitpod/common-go/log"14"github.com/google/uuid"15"google.golang.org/grpc/codes"16"google.golang.org/grpc/status"17"gorm.io/gorm"18)1920var CostCenterNotFound = errors.New("CostCenter not found")2122type BillingStrategy string2324const (25CostCenter_Stripe BillingStrategy = "stripe"26CostCenter_Other BillingStrategy = "other"27)2829type CostCenter struct {30ID AttributionID `gorm:"primary_key;column:id;type:char;size:36;" json:"id"`31CreationTime VarcharTime `gorm:"primary_key;column:creationTime;type:varchar;size:255;" json:"creationTime"`32SpendingLimit int32 `gorm:"column:spendingLimit;type:int;default:0;" json:"spendingLimit"`33BillingStrategy BillingStrategy `gorm:"column:billingStrategy;type:varchar;size:255;" json:"billingStrategy"`34BillingCycleStart VarcharTime `gorm:"column:billingCycleStart;type:varchar;size:255;" json:"billingCycleStart"`35NextBillingTime VarcharTime `gorm:"column:nextBillingTime;type:varchar;size:255;" json:"nextBillingTime"`36LastModified time.Time `gorm:"->;column:_lastModified;type:timestamp;default:CURRENT_TIMESTAMP(6);" json:"_lastModified"`37}3839// TableName sets the insert table name for this struct type40func (c *CostCenter) TableName() string {41return "d_b_cost_center"42}4344func (c *CostCenter) IsExpired() bool {45if !c.NextBillingTime.IsSet() {46return false47}4849return c.NextBillingTime.Time().Before(time.Now().UTC())50}5152type DefaultSpendingLimit struct {53ForTeams int32 `json:"forTeams"`54ForUsers int32 `json:"forUsers"`55MinForUsersOnStripe int32 `json:"minForUsersOnStripe"`56}5758func NewCostCenterManager(conn *gorm.DB, cfg DefaultSpendingLimit) *CostCenterManager {59return &CostCenterManager{60conn: conn,61cfg: cfg,62}63}6465type CostCenterManager struct {66conn *gorm.DB67cfg DefaultSpendingLimit68}6970// GetOrCreateCostCenter returns the latest version of cost center for the given attributionID.71// This method creates a codt center and stores it in the DB if there is no preexisting one.72func (c *CostCenterManager) GetOrCreateCostCenter(ctx context.Context, attributionID AttributionID) (CostCenter, error) {73logger := log.WithField("attributionId", attributionID)74now := time.Now().UTC()7576result, err := getCostCenter(ctx, c.conn, attributionID)77if err != nil {78if errors.Is(err, CostCenterNotFound) {79logger.Info("No existing cost center. Creating one.")80result = CostCenter{81ID: attributionID,82CreationTime: NewVarCharTime(now),83BillingStrategy: CostCenter_Other,84SpendingLimit: c.getSpendingLimitForNewCostCenter(attributionID),85BillingCycleStart: NewVarCharTime(now),86NextBillingTime: NewVarCharTime(now.AddDate(0, 1, 0)),87}88err := c.conn.Save(&result).Error89if err != nil {90if strings.HasPrefix(err.Error(), "Error 1062: Duplicate entry") {91// This can happen if we have multiple concurrent requests for the same attributionID.92logger.WithError(err).Info("Concurrent save.")93return getCostCenter(ctx, c.conn, attributionID)94}95return CostCenter{}, err96}97return result, nil98} else {99return CostCenter{}, err100}101}102103// If we retrieved a CostCenter which is not on Stripe, and the NextBillingPeriod is expired,104// we want to reset it immediately.105// This can happen in the following scenario:106// * User accesses gitpod just after their CostCenter expired, but just before our periodic CostCenter reset kicks in.107if result.BillingStrategy != CostCenter_Stripe && result.IsExpired() {108cc, err := c.ResetUsage(ctx, result.ID)109if err != nil {110logger.WithError(err).Error("Failed to reset expired usage.")111return CostCenter{}, fmt.Errorf("failed to reset usage for expired cost center ID: %s: %w", result.ID, err)112}113114return cc, nil115}116117return result, nil118}119120// computeDefaultSpendingLimit computes the spending limit for a new Organization.121// If the first joined member has not already granted credits to another org, we grant them the free credits allowance.122func (c *CostCenterManager) getSpendingLimitForNewCostCenter(attributionID AttributionID) int32 {123_, orgId := attributionID.Values()124orgUUID, err := uuid.Parse(orgId)125if err != nil {126log.WithError(err).WithField("attributionId", attributionID).Error("Failed to parse orgId.")127return c.cfg.ForTeams128}129130// fetch the first user that joined the org131var userId string132db := c.conn.Raw(`133SELECT userid134FROM d_b_team_membership135WHERE136teamId = ?137ORDER BY creationTime138LIMIT 1139`, orgId).Scan(&userId)140if db.Error != nil {141log.WithError(db.Error).WithField("attributionId", attributionID).Error("Failed to get userId for org.")142return c.cfg.ForTeams143}144145if userId == "" {146log.WithField("attributionId", attributionID).Error("Failed to get userId for org.")147return c.cfg.ForTeams148}149150userUUID, err := uuid.Parse(userId)151if err != nil {152log.WithError(err).WithField("attributionId", attributionID).Error("Failed to parse userId for org.")153return c.cfg.ForTeams154}155156// check if the user has already granted free credits to another org157type FreeCredit struct {158UserID uuid.UUID `gorm:"primary_key;column:userId;type:char(36)"`159Email string `gorm:"column:email;type:varchar(255)"`160OrganizationID uuid.UUID `gorm:"column:organizationId;type:char(36)"`161}162163// fetch primaryEmail from d_b_identity164var primaryEmail string165db = c.conn.Raw(`166SELECT primaryEmail167FROM d_b_identity168WHERE169userid = ?170LIMIT 1171`, userId).Scan(&primaryEmail)172if db.Error != nil {173log.WithError(db.Error).WithField("attributionId", attributionID).Error("Failed to get primaryEmail for user.")174return c.cfg.ForTeams175}176177var freeCredit FreeCredit178179// check if the user has already granted free credits to another org180db = c.conn.Table("d_b_free_credits").Where(&FreeCredit{UserID: userUUID}).Or(181&FreeCredit{Email: primaryEmail}).First(&freeCredit)182if db.Error != nil {183if errors.Is(db.Error, gorm.ErrRecordNotFound) {184// no record was found, so let's insert a new one185freeCredit = FreeCredit{UserID: userUUID, Email: primaryEmail, OrganizationID: orgUUID}186db = c.conn.Table("d_b_free_credits").Save(&freeCredit)187if db.Error != nil {188log.WithError(db.Error).WithField("attributionId", attributionID).Error("Failed to insert free credits.")189return c.cfg.ForTeams190}191return c.cfg.ForUsers192} else {193// some other database error occurred194log.WithError(db.Error).WithField("attributionId", attributionID).Error("Failed to get first org for user.")195return c.cfg.ForTeams196}197}198// a record was found, so we already granted free credits to another org199return c.cfg.ForTeams200}201202func getCostCenter(ctx context.Context, conn *gorm.DB, attributionId AttributionID) (CostCenter, error) {203db := conn.WithContext(ctx)204205var results []CostCenter206db = db.Where("id = ?", attributionId).Order("creationTime DESC").Limit(1).Find(&results)207if db.Error != nil {208return CostCenter{}, fmt.Errorf("failed to get cost center: %w", db.Error)209}210if len(results) == 0 {211return CostCenter{}, CostCenterNotFound212}213costCenter := results[0]214return costCenter, nil215}216217func (c *CostCenterManager) IncrementBillingCycle(ctx context.Context, attributionId AttributionID) (CostCenter, error) {218cc, err := getCostCenter(ctx, c.conn, attributionId)219if err != nil {220return CostCenter{}, err221}222now := time.Now().UTC()223if cc.NextBillingTime.Time().After(now) {224log.Infof("Cost center %s is not yet expired. Skipping increment.", attributionId)225return cc, nil226}227billingCycleStart := now228if cc.NextBillingTime.IsSet() {229billingCycleStart = cc.NextBillingTime.Time()230}231nextBillingTime := billingCycleStart.AddDate(0, 1, 0)232for nextBillingTime.Before(now) {233log.Warnf("Billing cycle for %s is lagging behind. Incrementing by one month.", attributionId)234billingCycleStart = billingCycleStart.AddDate(0, 1, 0)235nextBillingTime = billingCycleStart.AddDate(0, 1, 0)236}237// All fields on the new cost center remain the same, except for BillingCycleStart, NextBillingTime, and CreationTime238newCostCenter := CostCenter{239ID: cc.ID,240SpendingLimit: cc.SpendingLimit,241BillingStrategy: cc.BillingStrategy,242BillingCycleStart: NewVarCharTime(billingCycleStart),243NextBillingTime: NewVarCharTime(nextBillingTime),244CreationTime: NewVarCharTime(now),245}246err = c.conn.Save(&newCostCenter).Error247if err != nil {248return CostCenter{}, fmt.Errorf("failed to store cost center ID: %s", err)249}250return newCostCenter, nil251}252253func (c *CostCenterManager) UpdateCostCenter(ctx context.Context, newCC CostCenter) (CostCenter, error) {254if newCC.SpendingLimit < 0 {255return CostCenter{}, status.Errorf(codes.InvalidArgument, "Spending limit cannot be set below zero.")256}257258attributionID := newCC.ID259// retrieving the existing cost center to maintain the readonly values260existingCC, err := c.GetOrCreateCostCenter(ctx, newCC.ID)261if err != nil {262return CostCenter{}, status.Errorf(codes.NotFound, "cost center does not exist")263}264265now := time.Now()266267// we always update the creationTime268newCC.CreationTime = NewVarCharTime(now)269// we don't allow setting billingCycleStart or nextBillingTime from outside270newCC.BillingCycleStart = existingCC.BillingCycleStart271newCC.NextBillingTime = existingCC.NextBillingTime272273// Transitioning into free plan274if existingCC.BillingStrategy != CostCenter_Other && newCC.BillingStrategy == CostCenter_Other {275newCC.SpendingLimit, err = c.getPreviousSpendingLimit(newCC.ID)276if err != nil {277return CostCenter{}, err278}279newCC.BillingCycleStart = NewVarCharTime(now)280// see you next month281newCC.NextBillingTime = NewVarCharTime(now.AddDate(0, 1, 0))282}283284// Upgrading to Stripe285if existingCC.BillingStrategy != CostCenter_Stripe && newCC.BillingStrategy == CostCenter_Stripe {286err := c.BalanceOutUsage(ctx, attributionID, 0)287if err != nil {288return CostCenter{}, err289}290291newCC.BillingCycleStart = NewVarCharTime(now)292// set an informative nextBillingTime, even though we don't manage Stripe billing cycle293newCC.NextBillingTime = NewVarCharTime(now.AddDate(0, 1, 0))294}295296log.WithField("cost_center", newCC).Info("saving cost center.")297db := c.conn.Save(&newCC)298if db.Error != nil {299return CostCenter{}, fmt.Errorf("failed to save cost center for attributionID %s: %w", newCC.ID, db.Error)300}301return newCC, nil302}303304func (c *CostCenterManager) getPreviousSpendingLimit(attributionID AttributionID) (int32, error) {305var previousCostCenter CostCenter306// find the youngest cost center with billingStrategy='other'307db := c.conn.308Where("id = ? AND billingStrategy = ?", attributionID, CostCenter_Other).309Order("creationTime DESC").310Limit(1).311Find(&previousCostCenter)312if db.Error != nil {313return 0, fmt.Errorf("failed to get previous cost center: %w", db.Error)314}315if previousCostCenter.ID == "" {316return c.cfg.ForTeams, nil317}318return previousCostCenter.SpendingLimit, nil319}320321func (c *CostCenterManager) BalanceOutUsage(ctx context.Context, attributionID AttributionID, maxCreditCentsCovered CreditCents) error {322// moving to stripe -> let's run a finalization323finalizationUsage, err := c.newInvoiceUsageRecord(ctx, attributionID, maxCreditCentsCovered)324if err != nil {325return err326}327if finalizationUsage != nil {328err = UpdateUsage(ctx, c.conn, *finalizationUsage)329if err != nil {330return err331}332}333334return nil335}336337func (c *CostCenterManager) newInvoiceUsageRecord(ctx context.Context, attributionID AttributionID, maxCreditCentsCovered CreditCents) (*Usage, error) {338now := time.Now()339creditCents, err := GetBalance(ctx, c.conn, attributionID)340if err != nil {341return nil, err342}343if creditCents.ToCredits() <= 0 {344// account has no debt, do nothing345return nil, nil346}347if maxCreditCentsCovered != 0 && creditCents > maxCreditCentsCovered {348creditCents = maxCreditCentsCovered349}350return &Usage{351ID: uuid.New(),352AttributionID: attributionID,353Description: "Credits",354CreditCents: creditCents * -1,355EffectiveTime: NewVarCharTime(now),356Kind: InvoiceUsageKind,357Draft: false,358}, nil359}360361func (c *CostCenterManager) ListManagedCostCentersWithBillingTimeBefore(ctx context.Context, billingTimeBefore time.Time) ([]CostCenter, error) {362db := c.conn.WithContext(ctx)363364var results []CostCenter365var batch []CostCenter366367subquery := db.Table((&CostCenter{}).TableName()).368// Retrieve the latest CostCenter for a given (attribution) ID.369Select("DISTINCT id, MAX(creationTime) AS creationTime").370Group("id")371tx := db.Table(fmt.Sprintf("%s as cc", (&CostCenter{}).TableName())).372// Join on our set of latest CostCenter records373Joins("INNER JOIN (?) AS expiredCC on cc.id = expiredCC.id AND cc.creationTime = expiredCC.creationTime", subquery).374Where("cc.billingStrategy != ?", CostCenter_Stripe). // Stripe is managed externally375Where("nextBillingTime != ?", "").376Where("nextBillingTime < ?", TimeToISO8601(billingTimeBefore)).377FindInBatches(&batch, 1000, func(tx *gorm.DB, iteration int) error {378results = append(results, batch...)379return nil380})381382if tx.Error != nil {383return nil, fmt.Errorf("failed to list cost centers with billing time before: %w", tx.Error)384}385386return results, nil387}388389func (c *CostCenterManager) ResetUsage(ctx context.Context, id AttributionID) (CostCenter, error) {390logger := log.WithField("attribution_id", id)391cc, err := getCostCenter(ctx, c.conn, id)392if err != nil {393return cc, err394}395logger = logger.WithField("cost_center", cc)396if cc.BillingStrategy == CostCenter_Stripe {397return CostCenter{}, fmt.Errorf("cannot reset usage for Billing Strategy %s for Cost Center ID: %s", cc.BillingStrategy, cc.ID)398}399if !cc.IsExpired() {400logger.Info("Skipping ResetUsage because next billing cycle is in the future.")401return cc, nil402}403404logger.Info("Running `ResetUsage`.")405cc, err = c.IncrementBillingCycle(ctx, cc.ID)406if err != nil {407return CostCenter{}, fmt.Errorf("failed to increment billing cycle for AttributonID: %s: %w", cc.ID, err)408}409410// Create a synthetic Invoice Usage record, to reset usage411err = c.BalanceOutUsage(ctx, cc.ID, NewCreditCents(float64(cc.SpendingLimit)))412if err != nil {413return CostCenter{}, fmt.Errorf("failed to compute invocie usage record for AttributonID: %s: %w", cc.ID, err)414}415416return cc, nil417}418419420