Skip to content

[usage] Implement charge dispute handling - WEB-94 #17039

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 9 commits into from
Apr 11, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion components/gitpod-cli/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ module github.com/gitpod-io/gitpod/gitpod-cli
go 1.19

require (
github.com/bufbuild/connect-go v1.0.0
github.com/bufbuild/connect-go v1.5.2
github.com/creack/pty v1.1.17
github.com/gitpod-io/gitpod/common-go v0.0.0-00010101000000-000000000000
github.com/gitpod-io/gitpod/components/public-api/go v0.0.0-20230220133850-852f5cd5b180
Expand Down
3 changes: 3 additions & 0 deletions components/usage/BUILD.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,12 @@ packages:
- "**/*.go"
- "go.mod"
- "go.sum"
- "**/fixtures/*.yaml"
deps:
- components/gitpod-db/go:lib
- components/common-go:lib
- components/usage-api/go:lib
- components/public-api/go:lib
- components/content-service-api/go:lib
- components/gitpod-db/go:init-testdb
env:
Expand All @@ -24,6 +26,7 @@ packages:
- components/gitpod-db/go:lib
- components/common-go:lib
- components/usage-api/go:lib
- components/public-api/go:lib
- components/content-service-api/go:lib
- components/gitpod-db/go:init-testdb
srcs:
Expand Down
5 changes: 5 additions & 0 deletions components/usage/go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,10 @@ module github.com/gitpod-io/gitpod/usage
go 1.19

require (
github.com/bufbuild/connect-go v1.5.2
github.com/gitpod-io/gitpod/common-go v0.0.0-00010101000000-000000000000
github.com/gitpod-io/gitpod/components/gitpod-db/go v0.0.0-00010101000000-000000000000
github.com/gitpod-io/gitpod/components/public-api/go v0.0.0-00010101000000-000000000000
github.com/gitpod-io/gitpod/usage-api v0.0.0-00010101000000-000000000000
github.com/google/go-cmp v0.5.9
github.com/google/uuid v1.3.0
Expand All @@ -16,6 +18,7 @@ require (
github.com/stripe/stripe-go/v72 v72.114.0
google.golang.org/grpc v1.52.3
google.golang.org/protobuf v1.28.1
gopkg.in/dnaeon/go-vcr.v3 v3.1.2
gorm.io/gorm v1.24.1
)

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

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

replace github.com/gitpod-io/gitpod/components/public-api/go => ../public-api/go // leeway

replace github.com/gitpod-io/gitpod/content-service/api => ../content-service-api/go // leeway

replace github.com/gitpod-io/gitpod/usage-api => ../usage-api/go // leeway
Expand Down
4 changes: 4 additions & 0 deletions components/usage/go.sum

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

91 changes: 90 additions & 1 deletion components/usage/pkg/apiv1/billing.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,11 @@ import (
"math"
"time"

"github.com/bufbuild/connect-go"
"github.com/gitpod-io/gitpod/common-go/log"
db "github.com/gitpod-io/gitpod/components/gitpod-db/go"
experimental_v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1"
"github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1/v1connect"
v1 "github.com/gitpod-io/gitpod/usage-api/v1"
"github.com/gitpod-io/gitpod/usage/pkg/stripe"
"github.com/google/uuid"
Expand All @@ -23,12 +26,15 @@ import (
"gorm.io/gorm"
)

func NewBillingService(stripeClient *stripe.Client, conn *gorm.DB, ccManager *db.CostCenterManager, stripePrices stripe.StripePrices) *BillingService {
func NewBillingService(stripeClient *stripe.Client, conn *gorm.DB, ccManager *db.CostCenterManager, stripePrices stripe.StripePrices, teamsService v1connect.TeamsServiceClient, userService v1connect.UserServiceClient) *BillingService {
return &BillingService{
stripeClient: stripeClient,
conn: conn,
ccManager: ccManager,
stripePrices: stripePrices,

teamsService: teamsService,
userService: userService,
}
}

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

teamsService v1connect.TeamsServiceClient
userService v1connect.UserServiceClient

v1.UnimplementedBillingServiceServer
}

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

func (s *BillingService) OnChargeDispute(ctx context.Context, req *v1.OnChargeDisputeRequest) (*v1.OnChargeDisputeResponse, error) {
if req.DisputeId == "" {
return nil, status.Errorf(codes.InvalidArgument, "dispute ID is required")
}

logger := log.WithContext(ctx).WithField("disputeId", req.DisputeId)

dispute, err := s.stripeClient.GetDispute(ctx, req.DisputeId)
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to retrieve dispute ID %s from stripe", req.DisputeId)
}

if dispute.PaymentIntent == nil || dispute.PaymentIntent.Customer == nil {
return nil, status.Errorf(codes.Internal, "dispute did not contain customer of payment intent in expanded fields")
}

customer := dispute.PaymentIntent.Customer
logger = logger.WithField("customerId", customer.ID)

attributionIDValue, ok := customer.Metadata[stripe.AttributionIDMetadataKey]
if !ok {
return nil, status.Errorf(codes.Internal, "Customer %s object did not contain attribution ID in metadata", customer.ID)
}

logger = logger.WithField("attributionId", attributionIDValue)

attributionID, err := db.ParseAttributionID(attributionIDValue)
if err != nil {
log.WithError(err).Errorf("Failed to parse attribution ID from customer metadata.")
return nil, status.Errorf(codes.Internal, "failed to parse attribution ID from customer metadata")
}

var userIDsToBlock []string
entity, id := attributionID.Values()
switch entity {
case db.AttributionEntity_User:
// legacy for cases where we've not migrated the user to a team
// because we attribute to the user directly, we can just block the user directly
userIDsToBlock = append(userIDsToBlock, id)

case db.AttributionEntity_Team:
team, err := s.teamsService.GetTeam(ctx, connect.NewRequest(&experimental_v1.GetTeamRequest{
TeamId: id,
}))
if err != nil {
return nil, status.Errorf(codes.Internal, "failed to lookup team details for team ID: %s", id)
}

for _, member := range team.Msg.GetTeam().GetMembers() {
if member.GetRole() != experimental_v1.TeamRole_TEAM_ROLE_OWNER {
continue
}
userIDsToBlock = append(userIDsToBlock, member.GetUserId())
}

default:
return nil, status.Errorf(codes.Internal, "unknown attribution entity for %s", attributionIDValue)
}

logger = logger.WithField("teamOwners", userIDsToBlock)

logger.Infof("Identified %d users to block based on charge dispute", len(userIDsToBlock))
var errs []error
for _, userToBlock := range userIDsToBlock {
_, err := s.userService.BlockUser(ctx, connect.NewRequest(&experimental_v1.BlockUserRequest{
UserId: userToBlock,
Reason: fmt.Sprintf("User has created a Stripe dispute ID: %s", req.GetDisputeId()),
}))
if err != nil {
errs = append(errs, fmt.Errorf("failed to block user %s: %w", userToBlock, err))
}
}

if len(errs) > 0 {
return nil, status.Errorf(codes.Internal, "failed to block users: %v", errs)
}

return &v1.OnChargeDisputeResponse{}, nil
}

func (s *BillingService) getPriceId(ctx context.Context, attributionId string) string {
defaultPriceId := s.stripePrices.TeamUsagePriceIDs.USD
attributionID, err := db.ParseAttributionID(attributionId)
Expand Down
139 changes: 139 additions & 0 deletions components/usage/pkg/apiv1/billing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,154 @@ package apiv1
import (
"context"
"encoding/json"
"fmt"
"testing"

"github.com/bufbuild/connect-go"
db "github.com/gitpod-io/gitpod/components/gitpod-db/go"
"github.com/gitpod-io/gitpod/components/gitpod-db/go/dbtest"
experimental_v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1"
"github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1/v1connect"
v1 "github.com/gitpod-io/gitpod/usage-api/v1"
"github.com/gitpod-io/gitpod/usage/pkg/stripe"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
stripe_api "github.com/stripe/stripe-go/v72"
"gopkg.in/dnaeon/go-vcr.v3/cassette"
"gopkg.in/dnaeon/go-vcr.v3/recorder"
)

func TestBillingService_OnChargeDispute(t *testing.T) {
r := NewStripeRecorder(t, "stripe_on_charge_dispute")

client := r.GetDefaultClient()
stripeClient, err := stripe.NewWithHTTPClient(stripe.ClientConfig{
SecretKey: "testkey",
}, client)
require.NoError(t, err)

stubUserService := &StubUserService{}
svc := &BillingService{
stripeClient: stripeClient,
teamsService: &StubTeamsService{},
userService: stubUserService,
}

_, err = svc.OnChargeDispute(context.Background(), &v1.OnChargeDisputeRequest{
DisputeId: "dp_1MrLJpAyBDPbWrhawbWHEIDL",
})
require.NoError(t, err)

require.Equal(t, stubUserService.blockedUsers, []string{"owner_id"})
}

func NewStripeRecorder(t *testing.T, name string) *recorder.Recorder {
t.Helper()

r, err := recorder.New(fmt.Sprintf("fixtures/%s", name))
require.NoError(t, err)

t.Cleanup(func() {
r.Stop()
})

// Add a hook which removes Authorization headers from all requests
hook := func(i *cassette.Interaction) error {
delete(i.Request.Headers, "Authorization")
return nil
}
r.AddHook(hook, recorder.AfterCaptureHook)

if r.Mode() != recorder.ModeRecordOnce {
require.Fail(t, "Recorder should be in ModeRecordOnce")
}

return r
}

type StubTeamsService struct {
v1connect.TeamsServiceClient
}

func (s *StubTeamsService) CreateTeam(context.Context, *connect.Request[experimental_v1.CreateTeamRequest]) (*connect.Response[experimental_v1.CreateTeamResponse], error) {
return nil, nil
}

func (s *StubTeamsService) GetTeam(ctx context.Context, req *connect.Request[experimental_v1.GetTeamRequest]) (*connect.Response[experimental_v1.GetTeamResponse], error) {
// generate a stub which returns a team
team := &experimental_v1.Team{
Id: req.Msg.GetTeamId(),
Members: []*experimental_v1.TeamMember{
{
UserId: "owner_id",
Role: experimental_v1.TeamRole_TEAM_ROLE_OWNER,
},
{
UserId: "non_owner_id",
Role: experimental_v1.TeamRole_TEAM_ROLE_MEMBER,
},
},
}

return connect.NewResponse(&experimental_v1.GetTeamResponse{
Team: team,
}), nil
}

func (s *StubTeamsService) ListTeams(context.Context, *connect.Request[experimental_v1.ListTeamsRequest]) (*connect.Response[experimental_v1.ListTeamsResponse], error) {
return nil, nil
}
func (s *StubTeamsService) DeleteTeam(context.Context, *connect.Request[experimental_v1.DeleteTeamRequest]) (*connect.Response[experimental_v1.DeleteTeamResponse], error) {
return nil, nil
}
func (s *StubTeamsService) JoinTeam(context.Context, *connect.Request[experimental_v1.JoinTeamRequest]) (*connect.Response[experimental_v1.JoinTeamResponse], error) {
return nil, nil
}
func (s *StubTeamsService) ResetTeamInvitation(context.Context, *connect.Request[experimental_v1.ResetTeamInvitationRequest]) (*connect.Response[experimental_v1.ResetTeamInvitationResponse], error) {
return nil, nil
}
func (s *StubTeamsService) UpdateTeamMember(context.Context, *connect.Request[experimental_v1.UpdateTeamMemberRequest]) (*connect.Response[experimental_v1.UpdateTeamMemberResponse], error) {
return nil, nil
}
func (s *StubTeamsService) DeleteTeamMember(context.Context, *connect.Request[experimental_v1.DeleteTeamMemberRequest]) (*connect.Response[experimental_v1.DeleteTeamMemberResponse], error) {
return nil, nil
}

type StubUserService struct {
blockedUsers []string
}

func (s *StubUserService) GetAuthenticatedUser(context.Context, *connect.Request[experimental_v1.GetAuthenticatedUserRequest]) (*connect.Response[experimental_v1.GetAuthenticatedUserResponse], error) {
return nil, nil
}

// ListSSHKeys lists the public SSH keys.
func (s *StubUserService) ListSSHKeys(context.Context, *connect.Request[experimental_v1.ListSSHKeysRequest]) (*connect.Response[experimental_v1.ListSSHKeysResponse], error) {
return nil, nil
}

// CreateSSHKey adds a public SSH key.
func (s *StubUserService) CreateSSHKey(context.Context, *connect.Request[experimental_v1.CreateSSHKeyRequest]) (*connect.Response[experimental_v1.CreateSSHKeyResponse], error) {
return nil, nil
}

// GetSSHKey retrieves an ssh key by ID.
func (s *StubUserService) GetSSHKey(context.Context, *connect.Request[experimental_v1.GetSSHKeyRequest]) (*connect.Response[experimental_v1.GetSSHKeyResponse], error) {
return nil, nil
}

// DeleteSSHKey removes a public SSH key.
func (s *StubUserService) DeleteSSHKey(context.Context, *connect.Request[experimental_v1.DeleteSSHKeyRequest]) (*connect.Response[experimental_v1.DeleteSSHKeyResponse], error) {
return nil, nil
}
func (s *StubUserService) GetGitToken(context.Context, *connect.Request[experimental_v1.GetGitTokenRequest]) (*connect.Response[experimental_v1.GetGitTokenResponse], error) {
return nil, nil
}
func (s *StubUserService) BlockUser(ctx context.Context, req *connect.Request[experimental_v1.BlockUserRequest]) (*connect.Response[experimental_v1.BlockUserResponse], error) {
s.blockedUsers = append(s.blockedUsers, req.Msg.GetUserId())
return connect.NewResponse(&experimental_v1.BlockUserResponse{}), nil
}

func TestBalancesForStripeCostCenters(t *testing.T) {
attributionIDForStripe := db.NewUserAttributionID(uuid.New().String())
attributionIDForOther := db.NewTeamAttributionID(uuid.New().String())
Expand Down
Loading