Book a Demo!
CoCalc Logo Icon
StoreFeaturesDocsShareSupportNewsAboutPoliciesSign UpSign In
gitpod-io
GitHub Repository: gitpod-io/gitpod
Path: blob/main/components/gitpod-db/go/cost_center.go
2497 views
1
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.
2
// Licensed under the GNU Affero General Public License (AGPL).
3
// See License.AGPL.txt in the project root for license information.
4
5
package db
6
7
import (
8
"context"
9
"errors"
10
"fmt"
11
"strings"
12
"time"
13
14
"github.com/gitpod-io/gitpod/common-go/log"
15
"github.com/google/uuid"
16
"google.golang.org/grpc/codes"
17
"google.golang.org/grpc/status"
18
"gorm.io/gorm"
19
)
20
21
var CostCenterNotFound = errors.New("CostCenter not found")
22
23
type BillingStrategy string
24
25
const (
26
CostCenter_Stripe BillingStrategy = "stripe"
27
CostCenter_Other BillingStrategy = "other"
28
)
29
30
type CostCenter struct {
31
ID AttributionID `gorm:"primary_key;column:id;type:char;size:36;" json:"id"`
32
CreationTime VarcharTime `gorm:"primary_key;column:creationTime;type:varchar;size:255;" json:"creationTime"`
33
SpendingLimit int32 `gorm:"column:spendingLimit;type:int;default:0;" json:"spendingLimit"`
34
BillingStrategy BillingStrategy `gorm:"column:billingStrategy;type:varchar;size:255;" json:"billingStrategy"`
35
BillingCycleStart VarcharTime `gorm:"column:billingCycleStart;type:varchar;size:255;" json:"billingCycleStart"`
36
NextBillingTime VarcharTime `gorm:"column:nextBillingTime;type:varchar;size:255;" json:"nextBillingTime"`
37
LastModified time.Time `gorm:"->;column:_lastModified;type:timestamp;default:CURRENT_TIMESTAMP(6);" json:"_lastModified"`
38
}
39
40
// TableName sets the insert table name for this struct type
41
func (c *CostCenter) TableName() string {
42
return "d_b_cost_center"
43
}
44
45
func (c *CostCenter) IsExpired() bool {
46
if !c.NextBillingTime.IsSet() {
47
return false
48
}
49
50
return c.NextBillingTime.Time().Before(time.Now().UTC())
51
}
52
53
type DefaultSpendingLimit struct {
54
ForTeams int32 `json:"forTeams"`
55
ForUsers int32 `json:"forUsers"`
56
MinForUsersOnStripe int32 `json:"minForUsersOnStripe"`
57
}
58
59
func NewCostCenterManager(conn *gorm.DB, cfg DefaultSpendingLimit) *CostCenterManager {
60
return &CostCenterManager{
61
conn: conn,
62
cfg: cfg,
63
}
64
}
65
66
type CostCenterManager struct {
67
conn *gorm.DB
68
cfg DefaultSpendingLimit
69
}
70
71
// GetOrCreateCostCenter returns the latest version of cost center for the given attributionID.
72
// This method creates a codt center and stores it in the DB if there is no preexisting one.
73
func (c *CostCenterManager) GetOrCreateCostCenter(ctx context.Context, attributionID AttributionID) (CostCenter, error) {
74
logger := log.WithField("attributionId", attributionID)
75
now := time.Now().UTC()
76
77
result, err := getCostCenter(ctx, c.conn, attributionID)
78
if err != nil {
79
if errors.Is(err, CostCenterNotFound) {
80
logger.Info("No existing cost center. Creating one.")
81
result = CostCenter{
82
ID: attributionID,
83
CreationTime: NewVarCharTime(now),
84
BillingStrategy: CostCenter_Other,
85
SpendingLimit: c.getSpendingLimitForNewCostCenter(attributionID),
86
BillingCycleStart: NewVarCharTime(now),
87
NextBillingTime: NewVarCharTime(now.AddDate(0, 1, 0)),
88
}
89
err := c.conn.Save(&result).Error
90
if err != nil {
91
if strings.HasPrefix(err.Error(), "Error 1062: Duplicate entry") {
92
// This can happen if we have multiple concurrent requests for the same attributionID.
93
logger.WithError(err).Info("Concurrent save.")
94
return getCostCenter(ctx, c.conn, attributionID)
95
}
96
return CostCenter{}, err
97
}
98
return result, nil
99
} else {
100
return CostCenter{}, err
101
}
102
}
103
104
// If we retrieved a CostCenter which is not on Stripe, and the NextBillingPeriod is expired,
105
// we want to reset it immediately.
106
// This can happen in the following scenario:
107
// * User accesses gitpod just after their CostCenter expired, but just before our periodic CostCenter reset kicks in.
108
if result.BillingStrategy != CostCenter_Stripe && result.IsExpired() {
109
cc, err := c.ResetUsage(ctx, result.ID)
110
if err != nil {
111
logger.WithError(err).Error("Failed to reset expired usage.")
112
return CostCenter{}, fmt.Errorf("failed to reset usage for expired cost center ID: %s: %w", result.ID, err)
113
}
114
115
return cc, nil
116
}
117
118
return result, nil
119
}
120
121
// computeDefaultSpendingLimit computes the spending limit for a new Organization.
122
// If the first joined member has not already granted credits to another org, we grant them the free credits allowance.
123
func (c *CostCenterManager) getSpendingLimitForNewCostCenter(attributionID AttributionID) int32 {
124
_, orgId := attributionID.Values()
125
orgUUID, err := uuid.Parse(orgId)
126
if err != nil {
127
log.WithError(err).WithField("attributionId", attributionID).Error("Failed to parse orgId.")
128
return c.cfg.ForTeams
129
}
130
131
// fetch the first user that joined the org
132
var userId string
133
db := c.conn.Raw(`
134
SELECT userid
135
FROM d_b_team_membership
136
WHERE
137
teamId = ?
138
ORDER BY creationTime
139
LIMIT 1
140
`, orgId).Scan(&userId)
141
if db.Error != nil {
142
log.WithError(db.Error).WithField("attributionId", attributionID).Error("Failed to get userId for org.")
143
return c.cfg.ForTeams
144
}
145
146
if userId == "" {
147
log.WithField("attributionId", attributionID).Error("Failed to get userId for org.")
148
return c.cfg.ForTeams
149
}
150
151
userUUID, err := uuid.Parse(userId)
152
if err != nil {
153
log.WithError(err).WithField("attributionId", attributionID).Error("Failed to parse userId for org.")
154
return c.cfg.ForTeams
155
}
156
157
// check if the user has already granted free credits to another org
158
type FreeCredit struct {
159
UserID uuid.UUID `gorm:"primary_key;column:userId;type:char(36)"`
160
Email string `gorm:"column:email;type:varchar(255)"`
161
OrganizationID uuid.UUID `gorm:"column:organizationId;type:char(36)"`
162
}
163
164
// fetch primaryEmail from d_b_identity
165
var primaryEmail string
166
db = c.conn.Raw(`
167
SELECT primaryEmail
168
FROM d_b_identity
169
WHERE
170
userid = ?
171
LIMIT 1
172
`, userId).Scan(&primaryEmail)
173
if db.Error != nil {
174
log.WithError(db.Error).WithField("attributionId", attributionID).Error("Failed to get primaryEmail for user.")
175
return c.cfg.ForTeams
176
}
177
178
var freeCredit FreeCredit
179
180
// check if the user has already granted free credits to another org
181
db = c.conn.Table("d_b_free_credits").Where(&FreeCredit{UserID: userUUID}).Or(
182
&FreeCredit{Email: primaryEmail}).First(&freeCredit)
183
if db.Error != nil {
184
if errors.Is(db.Error, gorm.ErrRecordNotFound) {
185
// no record was found, so let's insert a new one
186
freeCredit = FreeCredit{UserID: userUUID, Email: primaryEmail, OrganizationID: orgUUID}
187
db = c.conn.Table("d_b_free_credits").Save(&freeCredit)
188
if db.Error != nil {
189
log.WithError(db.Error).WithField("attributionId", attributionID).Error("Failed to insert free credits.")
190
return c.cfg.ForTeams
191
}
192
return c.cfg.ForUsers
193
} else {
194
// some other database error occurred
195
log.WithError(db.Error).WithField("attributionId", attributionID).Error("Failed to get first org for user.")
196
return c.cfg.ForTeams
197
}
198
}
199
// a record was found, so we already granted free credits to another org
200
return c.cfg.ForTeams
201
}
202
203
func getCostCenter(ctx context.Context, conn *gorm.DB, attributionId AttributionID) (CostCenter, error) {
204
db := conn.WithContext(ctx)
205
206
var results []CostCenter
207
db = db.Where("id = ?", attributionId).Order("creationTime DESC").Limit(1).Find(&results)
208
if db.Error != nil {
209
return CostCenter{}, fmt.Errorf("failed to get cost center: %w", db.Error)
210
}
211
if len(results) == 0 {
212
return CostCenter{}, CostCenterNotFound
213
}
214
costCenter := results[0]
215
return costCenter, nil
216
}
217
218
func (c *CostCenterManager) IncrementBillingCycle(ctx context.Context, attributionId AttributionID) (CostCenter, error) {
219
cc, err := getCostCenter(ctx, c.conn, attributionId)
220
if err != nil {
221
return CostCenter{}, err
222
}
223
now := time.Now().UTC()
224
if cc.NextBillingTime.Time().After(now) {
225
log.Infof("Cost center %s is not yet expired. Skipping increment.", attributionId)
226
return cc, nil
227
}
228
billingCycleStart := now
229
if cc.NextBillingTime.IsSet() {
230
billingCycleStart = cc.NextBillingTime.Time()
231
}
232
nextBillingTime := billingCycleStart.AddDate(0, 1, 0)
233
for nextBillingTime.Before(now) {
234
log.Warnf("Billing cycle for %s is lagging behind. Incrementing by one month.", attributionId)
235
billingCycleStart = billingCycleStart.AddDate(0, 1, 0)
236
nextBillingTime = billingCycleStart.AddDate(0, 1, 0)
237
}
238
// All fields on the new cost center remain the same, except for BillingCycleStart, NextBillingTime, and CreationTime
239
newCostCenter := CostCenter{
240
ID: cc.ID,
241
SpendingLimit: cc.SpendingLimit,
242
BillingStrategy: cc.BillingStrategy,
243
BillingCycleStart: NewVarCharTime(billingCycleStart),
244
NextBillingTime: NewVarCharTime(nextBillingTime),
245
CreationTime: NewVarCharTime(now),
246
}
247
err = c.conn.Save(&newCostCenter).Error
248
if err != nil {
249
return CostCenter{}, fmt.Errorf("failed to store cost center ID: %s", err)
250
}
251
return newCostCenter, nil
252
}
253
254
func (c *CostCenterManager) UpdateCostCenter(ctx context.Context, newCC CostCenter) (CostCenter, error) {
255
if newCC.SpendingLimit < 0 {
256
return CostCenter{}, status.Errorf(codes.InvalidArgument, "Spending limit cannot be set below zero.")
257
}
258
259
attributionID := newCC.ID
260
// retrieving the existing cost center to maintain the readonly values
261
existingCC, err := c.GetOrCreateCostCenter(ctx, newCC.ID)
262
if err != nil {
263
return CostCenter{}, status.Errorf(codes.NotFound, "cost center does not exist")
264
}
265
266
now := time.Now()
267
268
// we always update the creationTime
269
newCC.CreationTime = NewVarCharTime(now)
270
// we don't allow setting billingCycleStart or nextBillingTime from outside
271
newCC.BillingCycleStart = existingCC.BillingCycleStart
272
newCC.NextBillingTime = existingCC.NextBillingTime
273
274
// Transitioning into free plan
275
if existingCC.BillingStrategy != CostCenter_Other && newCC.BillingStrategy == CostCenter_Other {
276
newCC.SpendingLimit, err = c.getPreviousSpendingLimit(newCC.ID)
277
if err != nil {
278
return CostCenter{}, err
279
}
280
newCC.BillingCycleStart = NewVarCharTime(now)
281
// see you next month
282
newCC.NextBillingTime = NewVarCharTime(now.AddDate(0, 1, 0))
283
}
284
285
// Upgrading to Stripe
286
if existingCC.BillingStrategy != CostCenter_Stripe && newCC.BillingStrategy == CostCenter_Stripe {
287
err := c.BalanceOutUsage(ctx, attributionID, 0)
288
if err != nil {
289
return CostCenter{}, err
290
}
291
292
newCC.BillingCycleStart = NewVarCharTime(now)
293
// set an informative nextBillingTime, even though we don't manage Stripe billing cycle
294
newCC.NextBillingTime = NewVarCharTime(now.AddDate(0, 1, 0))
295
}
296
297
log.WithField("cost_center", newCC).Info("saving cost center.")
298
db := c.conn.Save(&newCC)
299
if db.Error != nil {
300
return CostCenter{}, fmt.Errorf("failed to save cost center for attributionID %s: %w", newCC.ID, db.Error)
301
}
302
return newCC, nil
303
}
304
305
func (c *CostCenterManager) getPreviousSpendingLimit(attributionID AttributionID) (int32, error) {
306
var previousCostCenter CostCenter
307
// find the youngest cost center with billingStrategy='other'
308
db := c.conn.
309
Where("id = ? AND billingStrategy = ?", attributionID, CostCenter_Other).
310
Order("creationTime DESC").
311
Limit(1).
312
Find(&previousCostCenter)
313
if db.Error != nil {
314
return 0, fmt.Errorf("failed to get previous cost center: %w", db.Error)
315
}
316
if previousCostCenter.ID == "" {
317
return c.cfg.ForTeams, nil
318
}
319
return previousCostCenter.SpendingLimit, nil
320
}
321
322
func (c *CostCenterManager) BalanceOutUsage(ctx context.Context, attributionID AttributionID, maxCreditCentsCovered CreditCents) error {
323
// moving to stripe -> let's run a finalization
324
finalizationUsage, err := c.newInvoiceUsageRecord(ctx, attributionID, maxCreditCentsCovered)
325
if err != nil {
326
return err
327
}
328
if finalizationUsage != nil {
329
err = UpdateUsage(ctx, c.conn, *finalizationUsage)
330
if err != nil {
331
return err
332
}
333
}
334
335
return nil
336
}
337
338
func (c *CostCenterManager) newInvoiceUsageRecord(ctx context.Context, attributionID AttributionID, maxCreditCentsCovered CreditCents) (*Usage, error) {
339
now := time.Now()
340
creditCents, err := GetBalance(ctx, c.conn, attributionID)
341
if err != nil {
342
return nil, err
343
}
344
if creditCents.ToCredits() <= 0 {
345
// account has no debt, do nothing
346
return nil, nil
347
}
348
if maxCreditCentsCovered != 0 && creditCents > maxCreditCentsCovered {
349
creditCents = maxCreditCentsCovered
350
}
351
return &Usage{
352
ID: uuid.New(),
353
AttributionID: attributionID,
354
Description: "Credits",
355
CreditCents: creditCents * -1,
356
EffectiveTime: NewVarCharTime(now),
357
Kind: InvoiceUsageKind,
358
Draft: false,
359
}, nil
360
}
361
362
func (c *CostCenterManager) ListManagedCostCentersWithBillingTimeBefore(ctx context.Context, billingTimeBefore time.Time) ([]CostCenter, error) {
363
db := c.conn.WithContext(ctx)
364
365
var results []CostCenter
366
var batch []CostCenter
367
368
subquery := db.Table((&CostCenter{}).TableName()).
369
// Retrieve the latest CostCenter for a given (attribution) ID.
370
Select("DISTINCT id, MAX(creationTime) AS creationTime").
371
Group("id")
372
tx := db.Table(fmt.Sprintf("%s as cc", (&CostCenter{}).TableName())).
373
// Join on our set of latest CostCenter records
374
Joins("INNER JOIN (?) AS expiredCC on cc.id = expiredCC.id AND cc.creationTime = expiredCC.creationTime", subquery).
375
Where("cc.billingStrategy != ?", CostCenter_Stripe). // Stripe is managed externally
376
Where("nextBillingTime != ?", "").
377
Where("nextBillingTime < ?", TimeToISO8601(billingTimeBefore)).
378
FindInBatches(&batch, 1000, func(tx *gorm.DB, iteration int) error {
379
results = append(results, batch...)
380
return nil
381
})
382
383
if tx.Error != nil {
384
return nil, fmt.Errorf("failed to list cost centers with billing time before: %w", tx.Error)
385
}
386
387
return results, nil
388
}
389
390
func (c *CostCenterManager) ResetUsage(ctx context.Context, id AttributionID) (CostCenter, error) {
391
logger := log.WithField("attribution_id", id)
392
cc, err := getCostCenter(ctx, c.conn, id)
393
if err != nil {
394
return cc, err
395
}
396
logger = logger.WithField("cost_center", cc)
397
if cc.BillingStrategy == CostCenter_Stripe {
398
return CostCenter{}, fmt.Errorf("cannot reset usage for Billing Strategy %s for Cost Center ID: %s", cc.BillingStrategy, cc.ID)
399
}
400
if !cc.IsExpired() {
401
logger.Info("Skipping ResetUsage because next billing cycle is in the future.")
402
return cc, nil
403
}
404
405
logger.Info("Running `ResetUsage`.")
406
cc, err = c.IncrementBillingCycle(ctx, cc.ID)
407
if err != nil {
408
return CostCenter{}, fmt.Errorf("failed to increment billing cycle for AttributonID: %s: %w", cc.ID, err)
409
}
410
411
// Create a synthetic Invoice Usage record, to reset usage
412
err = c.BalanceOutUsage(ctx, cc.ID, NewCreditCents(float64(cc.SpendingLimit)))
413
if err != nil {
414
return CostCenter{}, fmt.Errorf("failed to compute invocie usage record for AttributonID: %s: %w", cc.ID, err)
415
}
416
417
return cc, nil
418
}
419
420