Skip to content

Commit f4babd3

Browse files
authored
[usage] Implement charge dispute handling - WEB-94 (gitpod-io#17039)
* [usage] Implement charge dispute handling * fix * fix * tests * Fix * Fix * fix * fix * fix
1 parent 41c14a1 commit f4babd3

File tree

11 files changed

+658
-7
lines changed

11 files changed

+658
-7
lines changed

components/gitpod-cli/go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/gitpod-io/gitpod/gitpod-cli
33
go 1.19
44

55
require (
6-
github.com/bufbuild/connect-go v1.0.0
6+
github.com/bufbuild/connect-go v1.5.2
77
github.com/creack/pty v1.1.17
88
github.com/gitpod-io/gitpod/common-go v0.0.0-00010101000000-000000000000
99
github.com/gitpod-io/gitpod/components/public-api/go v0.0.0-20230220133850-852f5cd5b180

components/usage/BUILD.yaml

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,12 @@ packages:
55
- "**/*.go"
66
- "go.mod"
77
- "go.sum"
8+
- "**/fixtures/*.yaml"
89
deps:
910
- components/gitpod-db/go:lib
1011
- components/common-go:lib
1112
- components/usage-api/go:lib
13+
- components/public-api/go:lib
1214
- components/content-service-api/go:lib
1315
- components/gitpod-db/go:init-testdb
1416
env:
@@ -24,6 +26,7 @@ packages:
2426
- components/gitpod-db/go:lib
2527
- components/common-go:lib
2628
- components/usage-api/go:lib
29+
- components/public-api/go:lib
2730
- components/content-service-api/go:lib
2831
- components/gitpod-db/go:init-testdb
2932
srcs:

components/usage/go.mod

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,8 +3,10 @@ module github.com/gitpod-io/gitpod/usage
33
go 1.19
44

55
require (
6+
github.com/bufbuild/connect-go v1.5.2
67
github.com/gitpod-io/gitpod/common-go v0.0.0-00010101000000-000000000000
78
github.com/gitpod-io/gitpod/components/gitpod-db/go v0.0.0-00010101000000-000000000000
9+
github.com/gitpod-io/gitpod/components/public-api/go v0.0.0-00010101000000-000000000000
810
github.com/gitpod-io/gitpod/usage-api v0.0.0-00010101000000-000000000000
911
github.com/google/go-cmp v0.5.9
1012
github.com/google/uuid v1.3.0
@@ -16,6 +18,7 @@ require (
1618
github.com/stripe/stripe-go/v72 v72.114.0
1719
google.golang.org/grpc v1.52.3
1820
google.golang.org/protobuf v1.28.1
21+
gopkg.in/dnaeon/go-vcr.v3 v3.1.2
1922
gorm.io/gorm v1.24.1
2023
)
2124

@@ -67,6 +70,8 @@ replace github.com/gitpod-io/gitpod/components/gitpod-db/go => ../gitpod-db/go /
6770

6871
replace github.com/gitpod-io/gitpod/common-go => ../common-go // leeway
6972

73+
replace github.com/gitpod-io/gitpod/components/public-api/go => ../public-api/go // leeway
74+
7075
replace github.com/gitpod-io/gitpod/content-service/api => ../content-service-api/go // leeway
7176

7277
replace github.com/gitpod-io/gitpod/usage-api => ../usage-api/go // leeway

components/usage/go.sum

Lines changed: 4 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

components/usage/pkg/apiv1/billing.go

Lines changed: 90 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,11 @@ import (
1212
"math"
1313
"time"
1414

15+
"github.com/bufbuild/connect-go"
1516
"github.com/gitpod-io/gitpod/common-go/log"
1617
db "github.com/gitpod-io/gitpod/components/gitpod-db/go"
18+
experimental_v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1"
19+
"github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1/v1connect"
1720
v1 "github.com/gitpod-io/gitpod/usage-api/v1"
1821
"github.com/gitpod-io/gitpod/usage/pkg/stripe"
1922
"github.com/google/uuid"
@@ -23,12 +26,15 @@ import (
2326
"gorm.io/gorm"
2427
)
2528

26-
func NewBillingService(stripeClient *stripe.Client, conn *gorm.DB, ccManager *db.CostCenterManager, stripePrices stripe.StripePrices) *BillingService {
29+
func NewBillingService(stripeClient *stripe.Client, conn *gorm.DB, ccManager *db.CostCenterManager, stripePrices stripe.StripePrices, teamsService v1connect.TeamsServiceClient, userService v1connect.UserServiceClient) *BillingService {
2730
return &BillingService{
2831
stripeClient: stripeClient,
2932
conn: conn,
3033
ccManager: ccManager,
3134
stripePrices: stripePrices,
35+
36+
teamsService: teamsService,
37+
userService: userService,
3238
}
3339
}
3440

@@ -38,6 +44,9 @@ type BillingService struct {
3844
ccManager *db.CostCenterManager
3945
stripePrices stripe.StripePrices
4046

47+
teamsService v1connect.TeamsServiceClient
48+
userService v1connect.UserServiceClient
49+
4150
v1.UnimplementedBillingServiceServer
4251
}
4352

@@ -408,6 +417,86 @@ func (s *BillingService) CancelSubscription(ctx context.Context, in *v1.CancelSu
408417
return &v1.CancelSubscriptionResponse{}, nil
409418
}
410419

420+
func (s *BillingService) OnChargeDispute(ctx context.Context, req *v1.OnChargeDisputeRequest) (*v1.OnChargeDisputeResponse, error) {
421+
if req.DisputeId == "" {
422+
return nil, status.Errorf(codes.InvalidArgument, "dispute ID is required")
423+
}
424+
425+
logger := log.WithContext(ctx).WithField("disputeId", req.DisputeId)
426+
427+
dispute, err := s.stripeClient.GetDispute(ctx, req.DisputeId)
428+
if err != nil {
429+
return nil, status.Errorf(codes.Internal, "failed to retrieve dispute ID %s from stripe", req.DisputeId)
430+
}
431+
432+
if dispute.PaymentIntent == nil || dispute.PaymentIntent.Customer == nil {
433+
return nil, status.Errorf(codes.Internal, "dispute did not contain customer of payment intent in expanded fields")
434+
}
435+
436+
customer := dispute.PaymentIntent.Customer
437+
logger = logger.WithField("customerId", customer.ID)
438+
439+
attributionIDValue, ok := customer.Metadata[stripe.AttributionIDMetadataKey]
440+
if !ok {
441+
return nil, status.Errorf(codes.Internal, "Customer %s object did not contain attribution ID in metadata", customer.ID)
442+
}
443+
444+
logger = logger.WithField("attributionId", attributionIDValue)
445+
446+
attributionID, err := db.ParseAttributionID(attributionIDValue)
447+
if err != nil {
448+
log.WithError(err).Errorf("Failed to parse attribution ID from customer metadata.")
449+
return nil, status.Errorf(codes.Internal, "failed to parse attribution ID from customer metadata")
450+
}
451+
452+
var userIDsToBlock []string
453+
entity, id := attributionID.Values()
454+
switch entity {
455+
case db.AttributionEntity_User:
456+
// legacy for cases where we've not migrated the user to a team
457+
// because we attribute to the user directly, we can just block the user directly
458+
userIDsToBlock = append(userIDsToBlock, id)
459+
460+
case db.AttributionEntity_Team:
461+
team, err := s.teamsService.GetTeam(ctx, connect.NewRequest(&experimental_v1.GetTeamRequest{
462+
TeamId: id,
463+
}))
464+
if err != nil {
465+
return nil, status.Errorf(codes.Internal, "failed to lookup team details for team ID: %s", id)
466+
}
467+
468+
for _, member := range team.Msg.GetTeam().GetMembers() {
469+
if member.GetRole() != experimental_v1.TeamRole_TEAM_ROLE_OWNER {
470+
continue
471+
}
472+
userIDsToBlock = append(userIDsToBlock, member.GetUserId())
473+
}
474+
475+
default:
476+
return nil, status.Errorf(codes.Internal, "unknown attribution entity for %s", attributionIDValue)
477+
}
478+
479+
logger = logger.WithField("teamOwners", userIDsToBlock)
480+
481+
logger.Infof("Identified %d users to block based on charge dispute", len(userIDsToBlock))
482+
var errs []error
483+
for _, userToBlock := range userIDsToBlock {
484+
_, err := s.userService.BlockUser(ctx, connect.NewRequest(&experimental_v1.BlockUserRequest{
485+
UserId: userToBlock,
486+
Reason: fmt.Sprintf("User has created a Stripe dispute ID: %s", req.GetDisputeId()),
487+
}))
488+
if err != nil {
489+
errs = append(errs, fmt.Errorf("failed to block user %s: %w", userToBlock, err))
490+
}
491+
}
492+
493+
if len(errs) > 0 {
494+
return nil, status.Errorf(codes.Internal, "failed to block users: %v", errs)
495+
}
496+
497+
return &v1.OnChargeDisputeResponse{}, nil
498+
}
499+
411500
func (s *BillingService) getPriceId(ctx context.Context, attributionId string) string {
412501
defaultPriceId := s.stripePrices.TeamUsagePriceIDs.USD
413502
attributionID, err := db.ParseAttributionID(attributionId)

components/usage/pkg/apiv1/billing_test.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,15 +7,154 @@ package apiv1
77
import (
88
"context"
99
"encoding/json"
10+
"fmt"
1011
"testing"
1112

13+
"github.com/bufbuild/connect-go"
1214
db "github.com/gitpod-io/gitpod/components/gitpod-db/go"
1315
"github.com/gitpod-io/gitpod/components/gitpod-db/go/dbtest"
16+
experimental_v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1"
17+
"github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1/v1connect"
18+
v1 "github.com/gitpod-io/gitpod/usage-api/v1"
19+
"github.com/gitpod-io/gitpod/usage/pkg/stripe"
1420
"github.com/google/uuid"
1521
"github.com/stretchr/testify/require"
1622
stripe_api "github.com/stripe/stripe-go/v72"
23+
"gopkg.in/dnaeon/go-vcr.v3/cassette"
24+
"gopkg.in/dnaeon/go-vcr.v3/recorder"
1725
)
1826

27+
func TestBillingService_OnChargeDispute(t *testing.T) {
28+
r := NewStripeRecorder(t, "stripe_on_charge_dispute")
29+
30+
client := r.GetDefaultClient()
31+
stripeClient, err := stripe.NewWithHTTPClient(stripe.ClientConfig{
32+
SecretKey: "testkey",
33+
}, client)
34+
require.NoError(t, err)
35+
36+
stubUserService := &StubUserService{}
37+
svc := &BillingService{
38+
stripeClient: stripeClient,
39+
teamsService: &StubTeamsService{},
40+
userService: stubUserService,
41+
}
42+
43+
_, err = svc.OnChargeDispute(context.Background(), &v1.OnChargeDisputeRequest{
44+
DisputeId: "dp_1MrLJpAyBDPbWrhawbWHEIDL",
45+
})
46+
require.NoError(t, err)
47+
48+
require.Equal(t, stubUserService.blockedUsers, []string{"owner_id"})
49+
}
50+
51+
func NewStripeRecorder(t *testing.T, name string) *recorder.Recorder {
52+
t.Helper()
53+
54+
r, err := recorder.New(fmt.Sprintf("fixtures/%s", name))
55+
require.NoError(t, err)
56+
57+
t.Cleanup(func() {
58+
r.Stop()
59+
})
60+
61+
// Add a hook which removes Authorization headers from all requests
62+
hook := func(i *cassette.Interaction) error {
63+
delete(i.Request.Headers, "Authorization")
64+
return nil
65+
}
66+
r.AddHook(hook, recorder.AfterCaptureHook)
67+
68+
if r.Mode() != recorder.ModeRecordOnce {
69+
require.Fail(t, "Recorder should be in ModeRecordOnce")
70+
}
71+
72+
return r
73+
}
74+
75+
type StubTeamsService struct {
76+
v1connect.TeamsServiceClient
77+
}
78+
79+
func (s *StubTeamsService) CreateTeam(context.Context, *connect.Request[experimental_v1.CreateTeamRequest]) (*connect.Response[experimental_v1.CreateTeamResponse], error) {
80+
return nil, nil
81+
}
82+
83+
func (s *StubTeamsService) GetTeam(ctx context.Context, req *connect.Request[experimental_v1.GetTeamRequest]) (*connect.Response[experimental_v1.GetTeamResponse], error) {
84+
// generate a stub which returns a team
85+
team := &experimental_v1.Team{
86+
Id: req.Msg.GetTeamId(),
87+
Members: []*experimental_v1.TeamMember{
88+
{
89+
UserId: "owner_id",
90+
Role: experimental_v1.TeamRole_TEAM_ROLE_OWNER,
91+
},
92+
{
93+
UserId: "non_owner_id",
94+
Role: experimental_v1.TeamRole_TEAM_ROLE_MEMBER,
95+
},
96+
},
97+
}
98+
99+
return connect.NewResponse(&experimental_v1.GetTeamResponse{
100+
Team: team,
101+
}), nil
102+
}
103+
104+
func (s *StubTeamsService) ListTeams(context.Context, *connect.Request[experimental_v1.ListTeamsRequest]) (*connect.Response[experimental_v1.ListTeamsResponse], error) {
105+
return nil, nil
106+
}
107+
func (s *StubTeamsService) DeleteTeam(context.Context, *connect.Request[experimental_v1.DeleteTeamRequest]) (*connect.Response[experimental_v1.DeleteTeamResponse], error) {
108+
return nil, nil
109+
}
110+
func (s *StubTeamsService) JoinTeam(context.Context, *connect.Request[experimental_v1.JoinTeamRequest]) (*connect.Response[experimental_v1.JoinTeamResponse], error) {
111+
return nil, nil
112+
}
113+
func (s *StubTeamsService) ResetTeamInvitation(context.Context, *connect.Request[experimental_v1.ResetTeamInvitationRequest]) (*connect.Response[experimental_v1.ResetTeamInvitationResponse], error) {
114+
return nil, nil
115+
}
116+
func (s *StubTeamsService) UpdateTeamMember(context.Context, *connect.Request[experimental_v1.UpdateTeamMemberRequest]) (*connect.Response[experimental_v1.UpdateTeamMemberResponse], error) {
117+
return nil, nil
118+
}
119+
func (s *StubTeamsService) DeleteTeamMember(context.Context, *connect.Request[experimental_v1.DeleteTeamMemberRequest]) (*connect.Response[experimental_v1.DeleteTeamMemberResponse], error) {
120+
return nil, nil
121+
}
122+
123+
type StubUserService struct {
124+
blockedUsers []string
125+
}
126+
127+
func (s *StubUserService) GetAuthenticatedUser(context.Context, *connect.Request[experimental_v1.GetAuthenticatedUserRequest]) (*connect.Response[experimental_v1.GetAuthenticatedUserResponse], error) {
128+
return nil, nil
129+
}
130+
131+
// ListSSHKeys lists the public SSH keys.
132+
func (s *StubUserService) ListSSHKeys(context.Context, *connect.Request[experimental_v1.ListSSHKeysRequest]) (*connect.Response[experimental_v1.ListSSHKeysResponse], error) {
133+
return nil, nil
134+
}
135+
136+
// CreateSSHKey adds a public SSH key.
137+
func (s *StubUserService) CreateSSHKey(context.Context, *connect.Request[experimental_v1.CreateSSHKeyRequest]) (*connect.Response[experimental_v1.CreateSSHKeyResponse], error) {
138+
return nil, nil
139+
}
140+
141+
// GetSSHKey retrieves an ssh key by ID.
142+
func (s *StubUserService) GetSSHKey(context.Context, *connect.Request[experimental_v1.GetSSHKeyRequest]) (*connect.Response[experimental_v1.GetSSHKeyResponse], error) {
143+
return nil, nil
144+
}
145+
146+
// DeleteSSHKey removes a public SSH key.
147+
func (s *StubUserService) DeleteSSHKey(context.Context, *connect.Request[experimental_v1.DeleteSSHKeyRequest]) (*connect.Response[experimental_v1.DeleteSSHKeyResponse], error) {
148+
return nil, nil
149+
}
150+
func (s *StubUserService) GetGitToken(context.Context, *connect.Request[experimental_v1.GetGitTokenRequest]) (*connect.Response[experimental_v1.GetGitTokenResponse], error) {
151+
return nil, nil
152+
}
153+
func (s *StubUserService) BlockUser(ctx context.Context, req *connect.Request[experimental_v1.BlockUserRequest]) (*connect.Response[experimental_v1.BlockUserResponse], error) {
154+
s.blockedUsers = append(s.blockedUsers, req.Msg.GetUserId())
155+
return connect.NewResponse(&experimental_v1.BlockUserResponse{}), nil
156+
}
157+
19158
func TestBalancesForStripeCostCenters(t *testing.T) {
20159
attributionIDForStripe := db.NewUserAttributionID(uuid.New().String())
21160
attributionIDForOther := db.NewTeamAttributionID(uuid.New().String())

0 commit comments

Comments
 (0)