Skip to content

[billing] Create Stripe invoices for teams based on their usage #10713

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 2 commits into from
Jun 17, 2022
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
13 changes: 4 additions & 9 deletions components/usage/pkg/controller/reconciler.go
Original file line number Diff line number Diff line change
Expand Up @@ -132,18 +132,13 @@ func (u *UsageReconciler) ReconcileTimeRange(ctx context.Context, from, to time.
}

func submitUsageReport(report []TeamUsage) {
var teamIdSet = make(map[string]bool)

// Convert the usage report into a set of teamIds occurring in the report
// Convert the usage report to sum all entries for the same team.
var summedReport = make(map[string]int64)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uint64?

Suggested change
var summedReport = make(map[string]int64)
var summedReport = make(map[string]uint64)

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We're using int64 elsewhere to store workspace runtimes, which already gives us way more range on the positive side than we need to store monthly usage, so I don't think a switch to uint64 is necessary.

for _, usageEntry := range report {
teamIdSet[usageEntry.TeamID] = true
}
teamIds := make([]string, 0, len(teamIdSet))
for k := range teamIdSet {
teamIds = append(teamIds, k)
summedReport[usageEntry.TeamID] += usageEntry.WorkspaceSeconds
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Go newbie question: There is no need to initialize new Map entries to 0 in Go? 👀 Or is this a feature of make?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, maps are initialised such that values have the default value for their type.

}

stripe.FindCustomersForTeamIds(teamIds)
stripe.UpdateUsage(summedReport)
}

func generateUsageReport(teams []teamWithWorkspaces, maxStopTime time.Time) ([]TeamUsage, error) {
Expand Down
48 changes: 45 additions & 3 deletions components/usage/pkg/stripe/stripe.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,13 +13,15 @@ import (
"github.com/gitpod-io/gitpod/common-go/log"
"github.com/stripe/stripe-go/v72"
"github.com/stripe/stripe-go/v72/customer"
"github.com/stripe/stripe-go/v72/usagerecord"
)

type stripeKeys struct {
PublishableKey string `json:"publishableKey"`
SecretKey string `json:"secretKey"`
}

// Authenticate authenticates the Stripe client using a provided file containing a Stripe secret key.
func Authenticate(apiKeyFile string) error {
bytes, err := os.ReadFile(apiKeyFile)
if err != nil {
Expand All @@ -36,19 +38,54 @@ func Authenticate(apiKeyFile string) error {
return nil
}

// FindCustomersForTeamIds queries the stripe API to find all customers with a teamId in `teamIds`.
func FindCustomersForTeamIds(teamIds []string) {
// UpdateUsage updates teams' Stripe subscriptions with usage data
// `usageForTeam` is a map from team name to total workspace seconds used within a billing period.
func UpdateUsage(usageForTeam map[string]int64) error {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

uint64?

Suggested change
func UpdateUsage(usageForTeam map[string]int64) error {
func UpdateUsage(usageForTeam map[string]uint64) error {

Copy link
Contributor Author

@andrew-farries andrew-farries Jun 17, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

teamIds := make([]string, 0, len(usageForTeam))
for k := range usageForTeam {
teamIds = append(teamIds, k)
}
queries := queriesForCustomersWithTeamIds(teamIds)

for _, query := range queries {
log.Infof("about to make query %q", query)
params := &stripe.CustomerSearchParams{SearchParams: stripe.SearchParams{Query: query}}
params := &stripe.CustomerSearchParams{
SearchParams: stripe.SearchParams{
Query: query,
Expand: []*string{stripe.String("data.subscriptions")},
},
}
iter := customer.Search(params)
for iter.Next() {
customer := iter.Customer()
log.Infof("found customer %q for teamId %q", customer.Name, customer.Metadata["teamId"])
subscriptions := customer.Subscriptions.Data
if len(subscriptions) != 1 {
log.Errorf("customer has an unexpected number of subscriptions (expected 1, got %d)", len(subscriptions))
continue
}
subscription := customer.Subscriptions.Data[0]

log.Infof("customer has subscription: %q", subscription.ID)
if len(subscription.Items.Data) != 1 {
log.Errorf("this subscription has an unexpected number of subscriptionItems (expected 1, got %d)", len(subscription.Items.Data))
continue
}

creditsUsed := workspaceSecondsToCredits(usageForTeam[customer.Metadata["teamId"]])

subscriptionItemId := subscription.Items.Data[0].ID
log.Infof("registering usage against subscriptionItem %q", subscriptionItemId)
_, err := usagerecord.New(&stripe.UsageRecordParams{
SubscriptionItem: stripe.String(subscriptionItemId),
Quantity: stripe.Int64(creditsUsed),
})
if err != nil {
log.WithError(err).Errorf("failed to register usage for customer %q", customer.Name)
}
}
}
return nil
}

// queriesForCustomersWithTeamIds constructs Stripe query strings to find the Stripe Customer for each teamId
Expand All @@ -72,3 +109,8 @@ func queriesForCustomersWithTeamIds(teamIds []string) []string {

return queries
}

// workspaceSecondsToCredits converts seconds (of workspace usage) into Stripe credits.
func workspaceSecondsToCredits(seconds int64) int64 {
return (seconds + 59) / 60
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, that's an interesting way to round up. I guess this is equivalent to something like Math.ceil(seconds / 60)?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yes, this is an easy way to ensure that we bill partial minutes as full minutes.

}
47 changes: 47 additions & 0 deletions components/usage/pkg/stripe/stripe_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -83,3 +83,50 @@ func TestCustomerQueriesForTeamIds_MultipleQueries(t *testing.T) {
})
}
}

func TestWorkspaceSecondsToCreditsCalcuation(t *testing.T) {
testCases := []struct {
Name string
Seconds int64
ExpectedCredits int64
}{
{
Name: "0 seconds",
Seconds: 0,
ExpectedCredits: 0,
},
{
Name: "1 second",
Seconds: 1,
ExpectedCredits: 1,
},
{
Name: "60 seconds",
Seconds: 60,
ExpectedCredits: 1,
},
{
Name: "61 seconds",
Seconds: 61,
ExpectedCredits: 2,
},
{
Name: "90 seconds",
Seconds: 90,
ExpectedCredits: 2,
},
{
Name: "1 hour",
Seconds: 3600,
ExpectedCredits: 60,
},
}

for _, tc := range testCases {
t.Run(tc.Name, func(t *testing.T) {
actualCredits := workspaceSecondsToCredits(tc.Seconds)

require.Equal(t, tc.ExpectedCredits, actualCredits)
})
}
}