Skip to content

Commit 5eda2d9

Browse files
committed
[stripe] Handle invoice finalization
1 parent e088f88 commit 5eda2d9

File tree

5 files changed

+143
-11
lines changed

5 files changed

+143
-11
lines changed

components/usage/pkg/apiv1/billing.go

+67-6
Original file line numberDiff line numberDiff line change
@@ -6,18 +6,20 @@ package apiv1
66

77
import (
88
"context"
9-
"github.com/gitpod-io/gitpod/usage/pkg/contentservice"
9+
1010
"math"
1111
"time"
1212

1313
"github.com/gitpod-io/gitpod/common-go/log"
1414
v1 "github.com/gitpod-io/gitpod/usage-api/v1"
15+
"github.com/gitpod-io/gitpod/usage/pkg/contentservice"
1516
"github.com/gitpod-io/gitpod/usage/pkg/db"
1617
"github.com/gitpod-io/gitpod/usage/pkg/stripe"
1718
"github.com/google/uuid"
1819
stripesdk "github.com/stripe/stripe-go/v72"
1920
"google.golang.org/grpc/codes"
2021
"google.golang.org/grpc/status"
22+
"google.golang.org/protobuf/types/known/timestamppb"
2123
"gorm.io/gorm"
2224
)
2325

@@ -31,12 +33,12 @@ func NewBillingService(stripeClient *stripe.Client, billInstancesAfter time.Time
3133
}
3234

3335
type BillingService struct {
34-
conn *gorm.DB
35-
stripeClient *stripe.Client
36-
billInstancesAfter time.Time
37-
36+
conn *gorm.DB
37+
stripeClient *stripe.Client
3838
contentService contentservice.Interface
3939

40+
billInstancesAfter time.Time
41+
4042
v1.UnimplementedBillingServiceServer
4143
}
4244

@@ -66,7 +68,66 @@ func (s *BillingService) UpdateInvoices(ctx context.Context, in *v1.UpdateInvoic
6668
}
6769

6870
func (s *BillingService) FinalizeInvoice(ctx context.Context, in *v1.FinalizeInvoiceRequest) (*v1.FinalizeInvoiceResponse, error) {
69-
log.Infof("Finalizing invoice for invoice %q", in.GetInvoiceId())
71+
logger := log.WithField("invoice_id", in.GetInvoiceId())
72+
73+
if in.GetInvoiceId() == "" {
74+
return nil, status.Errorf(codes.InvalidArgument, "Missing InvoiceID")
75+
}
76+
77+
invoice, err := s.stripeClient.GetInvoice(ctx, in.GetInvoiceId())
78+
if err != nil {
79+
logger.WithError(err).Error("Failed to retrieve invoice from Stripe.")
80+
return nil, status.Errorf(codes.NotFound, "Failed to get invoice with ID %s: %s", in.GetInvoiceId(), err.Error())
81+
}
82+
83+
reportID, found := invoice.Metadata[stripe.ReportIDMetadataKey]
84+
if !found {
85+
logger.Error("Failed to find report ID metadata on invoice from Stripe.")
86+
return nil, status.Errorf(codes.NotFound, "Invoice %s does not contain reportID", in.GetInvoiceId())
87+
}
88+
logger = logger.WithField("report_id", reportID)
89+
90+
subscription := invoice.Subscription
91+
if subscription == nil {
92+
logger.Error("No subscription information available for invoice.")
93+
return nil, status.Errorf(codes.Internal, "Failed to retrieve subscription details from invoice.")
94+
}
95+
96+
teamID, found := subscription.Metadata[stripe.TeamIDMetadataKey]
97+
if !found {
98+
logger.Error("Failed to find teamID from subscription metadata.")
99+
return nil, status.Errorf(codes.Internal, "Failed to extra teamID from Stripe subscription.")
100+
}
101+
logger = logger.WithField("team_id", teamID)
102+
103+
attributionID := db.NewTeamAttributionID(teamID)
104+
105+
// To support individual `user`s, we'll need to also extract the `userId` from metadata here and handle separately.
106+
107+
report, err := s.contentService.DownloadUsageReport(ctx, reportID)
108+
if err != nil {
109+
logger.WithError(err).Error("Failed to retrieve usage report from content service.")
110+
return nil, status.Errorf(codes.Internal, "Failed to download usage report.")
111+
}
112+
113+
invoicedSessions := report.GetUsageRecordsForAttributionID(attributionID)
114+
var errors []error
115+
for _, session := range invoicedSessions {
116+
_, err := s.SetBilledSession(ctx, &v1.SetBilledSessionRequest{
117+
InstanceId: session.InstanceID.String(),
118+
From: timestamppb.New(session.StartedAt),
119+
System: v1.System_SYSTEM_STRIPE,
120+
})
121+
if err != nil {
122+
logger.WithField("workspace_ignstance_id", session.InstanceID).WithError(err).Error("Failed to mark session as billed by Stripe.")
123+
errors = append(errors, err)
124+
}
125+
}
126+
127+
if len(errors) != 0 {
128+
logger.Errorf("Failed to mark %d sessions as billed. You have to update them manually!", len(errors))
129+
return nil, status.Errorf(codes.Internal, "Failed to mark %d sessions as billed by stripe.", len(errors))
130+
}
70131

71132
return &v1.FinalizeInvoiceResponse{}, nil
72133
}

components/usage/pkg/contentservice/usage_report.go

+11
Original file line numberDiff line numberDiff line change
@@ -25,3 +25,14 @@ type UsageReport struct {
2525

2626
UsageRecords []db.WorkspaceInstanceUsage `json:"usageRecords"`
2727
}
28+
29+
func (r *UsageReport) GetUsageRecordsForAttributionID(attributionID db.AttributionID) []db.WorkspaceInstanceUsage {
30+
var sessions []db.WorkspaceInstanceUsage
31+
for _, sess := range r.UsageRecords {
32+
if sess.AttributionID == attributionID {
33+
sessions = append(sessions, sess)
34+
}
35+
}
36+
37+
return sessions
38+
}
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
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 contentservice
6+
7+
import (
8+
"github.com/gitpod-io/gitpod/usage/pkg/db"
9+
"github.com/gitpod-io/gitpod/usage/pkg/db/dbtest"
10+
"github.com/google/uuid"
11+
"github.com/stretchr/testify/require"
12+
"testing"
13+
"time"
14+
)
15+
16+
func TestUsageReport_GetUsageRecordsForAttributionID(t *testing.T) {
17+
attributionID_A, attributionID_B := db.NewTeamAttributionID(uuid.New().String()), db.NewTeamAttributionID(uuid.New().String())
18+
19+
report := UsageReport{
20+
GenerationTime: time.Now(),
21+
From: time.Now(),
22+
To: time.Now(),
23+
UsageRecords: []db.WorkspaceInstanceUsage{
24+
dbtest.NewWorkspaceInstanceUsage(t, db.WorkspaceInstanceUsage{
25+
AttributionID: attributionID_A,
26+
}),
27+
dbtest.NewWorkspaceInstanceUsage(t, db.WorkspaceInstanceUsage{
28+
AttributionID: attributionID_A,
29+
}),
30+
dbtest.NewWorkspaceInstanceUsage(t, db.WorkspaceInstanceUsage{
31+
AttributionID: attributionID_B,
32+
}),
33+
},
34+
}
35+
36+
filteredToAttributionA := report.GetUsageRecordsForAttributionID(attributionID_A)
37+
require.Equal(t, []db.WorkspaceInstanceUsage{report.UsageRecords[0], report.UsageRecords[1]}, filteredToAttributionA)
38+
39+
filteredToAttributionB := report.GetUsageRecordsForAttributionID(attributionID_B)
40+
require.Equal(t, []db.WorkspaceInstanceUsage{report.UsageRecords[2]}, filteredToAttributionB)
41+
42+
filteredToAbsentAttribution := report.GetUsageRecordsForAttributionID(db.NewTeamAttributionID(uuid.New().String()))
43+
require.Len(t, filteredToAbsentAttribution, 0)
44+
}

components/usage/pkg/db/workspace_instance_usage.go

-2
Original file line numberDiff line numberDiff line change
@@ -89,5 +89,3 @@ func ListUsage(ctx context.Context, conn *gorm.DB, attributionId AttributionID,
8989
}
9090
return usageRecords, nil
9191
}
92-
93-
type UsageReport []WorkspaceInstanceUsage

components/usage/pkg/stripe/stripe.go

+21-3
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,8 @@ import (
1717
)
1818

1919
const (
20-
reportIDMetadataKey = "reportId"
20+
ReportIDMetadataKey = "reportId"
21+
TeamIDMetadataKey = "teamId"
2122
)
2223

2324
type Client struct {
@@ -157,7 +158,7 @@ func (c *Client) updateUsageForCustomer(ctx context.Context, customer *stripe.Cu
157158
}
158159

159160
_, err = c.UpdateInvoiceMetadata(ctx, invoice.ID, map[string]string{
160-
reportIDMetadataKey: summary.ReportID,
161+
ReportIDMetadataKey: summary.ReportID,
161162
})
162163
if err != nil {
163164
return nil, fmt.Errorf("failed to udpate invoice %s metadata with report ID: %w", invoice.ID, err)
@@ -240,6 +241,23 @@ func (c *Client) UpdateInvoiceMetadata(ctx context.Context, invoiceID string, me
240241
return invoice, nil
241242
}
242243

244+
func (c *Client) GetInvoice(ctx context.Context, invoiceID string) (*stripe.Invoice, error) {
245+
if invoiceID == "" {
246+
return nil, fmt.Errorf("no invoice ID specified")
247+
}
248+
249+
invoice, err := c.sc.Invoices.Get(invoiceID, &stripe.InvoiceParams{
250+
Params: stripe.Params{
251+
Context: ctx,
252+
Expand: []*string{stripe.String("data.subscriptions")},
253+
},
254+
})
255+
if err != nil {
256+
return nil, fmt.Errorf("failed to get invoice %s: %w", invoiceID, err)
257+
}
258+
return invoice, nil
259+
}
260+
243261
// queriesForCustomersWithTeamIds constructs Stripe query strings to find the Stripe Customer for each teamId
244262
// It returns multiple queries, each being a big disjunction of subclauses so that we can process multiple teamIds in one query.
245263
// `clausesPerQuery` is a limit enforced by the Stripe API.
@@ -251,7 +269,7 @@ func queriesForCustomersWithTeamIds(teamIds []string) []string {
251269
for i := 0; i < len(teamIds); i += clausesPerQuery {
252270
sb.Reset()
253271
for j := 0; j < clausesPerQuery && i+j < len(teamIds); j++ {
254-
sb.WriteString(fmt.Sprintf("metadata['teamId']:'%s'", teamIds[i+j]))
272+
sb.WriteString(fmt.Sprintf("metadata['%s']:'%s'", TeamIDMetadataKey, teamIds[i+j]))
255273
if j < clausesPerQuery-1 && i+j < len(teamIds)-1 {
256274
sb.WriteString(" OR ")
257275
}

0 commit comments

Comments
 (0)