Skip to content

Commit a508cee

Browse files
committed
[usage] Implement BillingService
1 parent c582420 commit a508cee

File tree

9 files changed

+253
-163
lines changed

9 files changed

+253
-163
lines changed

components/usage/pkg/apiv1/billing.go

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
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 apiv1
6+
7+
import (
8+
"context"
9+
"fmt"
10+
"github.com/gitpod-io/gitpod/common-go/log"
11+
v1 "github.com/gitpod-io/gitpod/usage-api/v1"
12+
"github.com/gitpod-io/gitpod/usage/pkg/db"
13+
"github.com/gitpod-io/gitpod/usage/pkg/stripe"
14+
"google.golang.org/grpc/codes"
15+
"google.golang.org/grpc/status"
16+
"math"
17+
)
18+
19+
func NewBillingService(stripeClient *stripe.Client) *BillingService {
20+
return &BillingService{
21+
stripeClient: stripeClient,
22+
}
23+
}
24+
25+
type BillingService struct {
26+
stripeClient *stripe.Client
27+
28+
v1.UnimplementedBillingServiceServer
29+
}
30+
31+
func (s *BillingService) UpdateInvoices(ctx context.Context, in *v1.UpdateInvoicesRequest) (*v1.UpdateInvoicesResponse, error) {
32+
credits, err := creditSummaryForTeams(in.GetSessions())
33+
if err != nil {
34+
log.Log.WithError(err).Errorf("Failed to compute credit summary.")
35+
return nil, status.Errorf(codes.InvalidArgument, "failed to compute credit summary")
36+
}
37+
38+
err = s.stripeClient.UpdateUsage(ctx, credits)
39+
if err != nil {
40+
log.Log.WithError(err).Errorf("Failed to update stripe invoices.")
41+
return nil, status.Errorf(codes.Internal, "failed to update stripe invoices")
42+
}
43+
44+
return &v1.UpdateInvoicesResponse{}, nil
45+
}
46+
47+
func creditSummaryForTeams(sessions []*v1.BilledSession) (map[string]int64, error) {
48+
creditsPerTeamID := map[string]float64{}
49+
50+
for _, session := range sessions {
51+
attributionID, err := db.ParseAttributionID(session.AttributionId)
52+
if err != nil {
53+
return nil, fmt.Errorf("failed to parse attribution ID: %w", err)
54+
}
55+
56+
entity, id := attributionID.Values()
57+
if entity != db.AttributionEntity_Team {
58+
continue
59+
}
60+
61+
if _, ok := creditsPerTeamID[id]; !ok {
62+
creditsPerTeamID[id] = 0
63+
}
64+
65+
creditsPerTeamID[id] += session.GetCredits()
66+
}
67+
68+
rounded := map[string]int64{}
69+
for teamID, credits := range creditsPerTeamID {
70+
rounded[teamID] = int64(math.Ceil(credits))
71+
}
72+
73+
return rounded, nil
74+
}
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
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 apiv1
6+
7+
import (
8+
"context"
9+
"github.com/gitpod-io/gitpod/common-go/log"
10+
v1 "github.com/gitpod-io/gitpod/usage-api/v1"
11+
)
12+
13+
// BillingServiceNoop is used for Self-Hosted installations
14+
type BillingServiceNoop struct {
15+
v1.UnimplementedBillingServiceServer
16+
}
17+
18+
func (s *BillingServiceNoop) UpdateInvoices(_ context.Context, _ *v1.UpdateInvoicesRequest) (*v1.UpdateInvoicesResponse, error) {
19+
log.Log.Infof("UpdateInvoices RPC invoked in no-op mode, no invoices will be updated.")
20+
return &v1.UpdateInvoicesResponse{}, nil
21+
}
Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,66 @@
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 apiv1
6+
7+
import (
8+
v1 "github.com/gitpod-io/gitpod/usage-api/v1"
9+
"github.com/gitpod-io/gitpod/usage/pkg/db"
10+
"github.com/google/uuid"
11+
"github.com/stretchr/testify/require"
12+
"testing"
13+
)
14+
15+
func TestCreditSummaryForTeams(t *testing.T) {
16+
teamID := uuid.New().String()
17+
teamAttributionID := db.NewTeamAttributionID(teamID)
18+
19+
scenarios := []struct {
20+
Name string
21+
Sessions []*v1.BilledSession
22+
Expected map[string]int64
23+
}{
24+
{
25+
Name: "no instances in report, no summary",
26+
Sessions: []*v1.BilledSession{},
27+
Expected: map[string]int64{},
28+
},
29+
{
30+
Name: "skips user attributions",
31+
Sessions: []*v1.BilledSession{
32+
{
33+
AttributionId: string(db.NewUserAttributionID(uuid.New().String())),
34+
},
35+
},
36+
Expected: map[string]int64{},
37+
},
38+
{
39+
Name: "two workspace instances",
40+
Sessions: []*v1.BilledSession{
41+
{
42+
// has 1 day and 23 hours of usage
43+
AttributionId: string(teamAttributionID),
44+
Credits: (24 + 23) * 10,
45+
},
46+
{
47+
// has 1 hour of usage
48+
AttributionId: string(teamAttributionID),
49+
Credits: 10,
50+
},
51+
},
52+
Expected: map[string]int64{
53+
// total of 2 days runtime, at 10 credits per hour, that's 480 credits
54+
teamID: 480,
55+
},
56+
},
57+
}
58+
59+
for _, s := range scenarios {
60+
t.Run(s.Name, func(t *testing.T) {
61+
actual, err := creditSummaryForTeams(s.Sessions)
62+
require.NoError(t, err)
63+
require.Equal(t, s.Expected, actual)
64+
})
65+
}
66+
}

components/usage/pkg/controller/billing.go renamed to components/usage/pkg/controller/pricer.go

Lines changed: 0 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -5,44 +5,12 @@
55
package controller
66

77
import (
8-
"context"
98
"fmt"
109
"time"
1110

1211
"github.com/gitpod-io/gitpod/usage/pkg/db"
13-
"github.com/gitpod-io/gitpod/usage/pkg/stripe"
1412
)
1513

16-
type BillingController interface {
17-
Reconcile(ctx context.Context, report UsageReport) error
18-
}
19-
20-
type NoOpBillingController struct{}
21-
22-
func (b *NoOpBillingController) Reconcile(_ context.Context, _ UsageReport) error {
23-
return nil
24-
}
25-
26-
type StripeBillingController struct {
27-
sc *stripe.Client
28-
}
29-
30-
func NewStripeBillingController(sc *stripe.Client) *StripeBillingController {
31-
return &StripeBillingController{
32-
sc: sc,
33-
}
34-
}
35-
36-
func (b *StripeBillingController) Reconcile(ctx context.Context, report UsageReport) error {
37-
runtimeReport := report.CreditSummaryForTeams()
38-
39-
err := b.sc.UpdateUsage(ctx, runtimeReport)
40-
if err != nil {
41-
return fmt.Errorf("failed to update usage: %w", err)
42-
}
43-
return nil
44-
}
45-
4614
const (
4715
defaultWorkspaceClass = "default"
4816
)

components/usage/pkg/controller/reconciler.go

Lines changed: 41 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ import (
88
"context"
99
"database/sql"
1010
"fmt"
11-
"math"
11+
v1 "github.com/gitpod-io/gitpod/usage-api/v1"
12+
"google.golang.org/protobuf/types/known/timestamppb"
1213
"time"
1314

1415
"github.com/gitpod-io/gitpod/common-go/log"
@@ -29,20 +30,20 @@ func (f ReconcilerFunc) Reconcile() error {
2930
}
3031

3132
type UsageReconciler struct {
32-
nowFunc func() time.Time
33-
conn *gorm.DB
34-
pricer *WorkspacePricer
35-
billingController BillingController
36-
contentService contentservice.Interface
33+
nowFunc func() time.Time
34+
conn *gorm.DB
35+
pricer *WorkspacePricer
36+
billingService v1.BillingServiceClient
37+
contentService contentservice.Interface
3738
}
3839

39-
func NewUsageReconciler(conn *gorm.DB, pricer *WorkspacePricer, billingController BillingController, contentService contentservice.Interface) *UsageReconciler {
40+
func NewUsageReconciler(conn *gorm.DB, pricer *WorkspacePricer, billingClient v1.BillingServiceClient, contentService contentservice.Interface) *UsageReconciler {
4041
return &UsageReconciler{
41-
conn: conn,
42-
pricer: pricer,
43-
billingController: billingController,
44-
contentService: contentService,
45-
nowFunc: time.Now,
42+
conn: conn,
43+
pricer: pricer,
44+
billingService: billingClient,
45+
contentService: contentService,
46+
nowFunc: time.Now,
4647
}
4748
}
4849

@@ -106,11 +107,14 @@ func (u *UsageReconciler) ReconcileTimeRange(ctx context.Context, from, to time.
106107
log.WithField("workspace_instances", instances).Debug("Successfully loaded workspace instances.")
107108

108109
usageRecords := instancesToUsageRecords(instances, u.pricer, now)
109-
//instancesByAttributionID := groupInstancesByAttributionID(instances)
110110

111-
err = u.billingController.Reconcile(ctx, usageRecords)
111+
_, err = u.billingService.UpdateInvoices(ctx, &v1.UpdateInvoicesRequest{
112+
StartTime: timestamppb.New(from),
113+
EndTime: timestamppb.New(to),
114+
Sessions: instancesToBilledSessions(usageRecords),
115+
})
112116
if err != nil {
113-
return nil, nil, fmt.Errorf("failed to reconcile billing: %w", err)
117+
return nil, nil, fmt.Errorf("failed to update invoices: %w", err)
114118
}
115119

116120
return status, usageRecords, nil
@@ -148,31 +152,36 @@ func instancesToUsageRecords(instances []db.WorkspaceInstanceForUsage, pricer *W
148152
return usageRecords
149153
}
150154

151-
type UsageReport []db.WorkspaceInstanceUsage
152-
153-
func (u UsageReport) CreditSummaryForTeams() map[string]int64 {
154-
creditsPerTeamID := map[string]int64{}
155+
func instancesToBilledSessions(instances []db.WorkspaceInstanceUsage) []*v1.BilledSession {
156+
var sessions []*v1.BilledSession
155157

156-
for _, instance := range u {
157-
entity, id := instance.AttributionID.Values()
158-
if entity != db.AttributionEntity_Team {
159-
continue
160-
}
158+
for _, instance := range instances {
159+
var endTime *timestamppb.Timestamp
161160

162-
if _, ok := creditsPerTeamID[id]; !ok {
163-
creditsPerTeamID[id] = 0
161+
if instance.StoppedAt.Valid {
162+
endTime = timestamppb.New(instance.StoppedAt.Time)
164163
}
165164

166-
creditsPerTeamID[id] += int64(instance.CreditsUsed)
167-
}
168-
169-
for teamID, credits := range creditsPerTeamID {
170-
creditsPerTeamID[teamID] = int64(math.Ceil(float64(credits)))
165+
sessions = append(sessions, &v1.BilledSession{
166+
AttributionId: string(instance.AttributionID),
167+
UserId: instance.UserID.String(),
168+
TeamId: "",
169+
WorkspaceId: instance.WorkspaceID,
170+
WorkspaceType: string(instance.WorkspaceType),
171+
ProjectId: instance.ProjectID,
172+
InstanceId: instance.InstanceID.String(),
173+
WorkspaceClass: instance.WorkspaceClass,
174+
StartTime: timestamppb.New(instance.StartedAt),
175+
EndTime: endTime,
176+
Credits: int64(instance.CreditsUsed),
177+
})
171178
}
172179

173-
return creditsPerTeamID
180+
return sessions
174181
}
175182

183+
type UsageReport []db.WorkspaceInstanceUsage
184+
176185
type invalidWorkspaceInstance struct {
177186
reason string
178187
workspaceInstanceID uuid.UUID

0 commit comments

Comments
 (0)