Skip to content

Commit 8271404

Browse files
author
Laurie T. Malau
committed
[usage] Implement CreateStripeSubscription
1 parent dd97e9e commit 8271404

File tree

9 files changed

+274
-96
lines changed

9 files changed

+274
-96
lines changed

components/server/ee/src/workspace/gitpod-server-impl.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2174,9 +2174,13 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
21742174
throw new Error(`No Stripe customer profile for '${attributionId}'`);
21752175
}
21762176

2177+
//TODO: feature flag with below
2178+
// await this.billingService.createStripeSubscription({attributionId,customerId,setupIntentId,usageLimit});
2179+
21772180
await this.stripeService.setDefaultPaymentMethodForCustomer(customerId, setupIntentId);
21782181
await this.stripeService.createSubscriptionForCustomer(customerId, attributionId);
21792182

2183+
// TODO maybe: Also move this call to setCostCenter to billingService.createStripeSubscription
21802184
// Creating a cost center for this customer
21812185
const { costCenter } = await this.usageService.setCostCenter({
21822186
costCenter: {

components/usage-api/go/v1/billing.pb.go

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

components/usage-api/typescript/src/usage/v1/billing.pb.ts

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ export interface CreateStripeCustomerResponse {
5959

6060
export interface CreateStripeSubscriptionRequest {
6161
attributionId: string;
62+
customerId: string;
6263
setupIntentId: string;
6364
usageLimit: number;
6465
}
@@ -623,19 +624,22 @@ export const CreateStripeCustomerResponse = {
623624
};
624625

625626
function createBaseCreateStripeSubscriptionRequest(): CreateStripeSubscriptionRequest {
626-
return { attributionId: "", setupIntentId: "", usageLimit: 0 };
627+
return { attributionId: "", customerId: "", setupIntentId: "", usageLimit: 0 };
627628
}
628629

629630
export const CreateStripeSubscriptionRequest = {
630631
encode(message: CreateStripeSubscriptionRequest, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer {
631632
if (message.attributionId !== "") {
632633
writer.uint32(10).string(message.attributionId);
633634
}
635+
if (message.customerId !== "") {
636+
writer.uint32(18).string(message.customerId);
637+
}
634638
if (message.setupIntentId !== "") {
635-
writer.uint32(18).string(message.setupIntentId);
639+
writer.uint32(26).string(message.setupIntentId);
636640
}
637641
if (message.usageLimit !== 0) {
638-
writer.uint32(24).int64(message.usageLimit);
642+
writer.uint32(32).int64(message.usageLimit);
639643
}
640644
return writer;
641645
},
@@ -651,9 +655,12 @@ export const CreateStripeSubscriptionRequest = {
651655
message.attributionId = reader.string();
652656
break;
653657
case 2:
654-
message.setupIntentId = reader.string();
658+
message.customerId = reader.string();
655659
break;
656660
case 3:
661+
message.setupIntentId = reader.string();
662+
break;
663+
case 4:
657664
message.usageLimit = longToNumber(reader.int64() as Long);
658665
break;
659666
default:
@@ -667,6 +674,7 @@ export const CreateStripeSubscriptionRequest = {
667674
fromJSON(object: any): CreateStripeSubscriptionRequest {
668675
return {
669676
attributionId: isSet(object.attributionId) ? String(object.attributionId) : "",
677+
customerId: isSet(object.customerId) ? String(object.customerId) : "",
670678
setupIntentId: isSet(object.setupIntentId) ? String(object.setupIntentId) : "",
671679
usageLimit: isSet(object.usageLimit) ? Number(object.usageLimit) : 0,
672680
};
@@ -675,6 +683,7 @@ export const CreateStripeSubscriptionRequest = {
675683
toJSON(message: CreateStripeSubscriptionRequest): unknown {
676684
const obj: any = {};
677685
message.attributionId !== undefined && (obj.attributionId = message.attributionId);
686+
message.customerId !== undefined && (obj.customerId = message.customerId);
678687
message.setupIntentId !== undefined && (obj.setupIntentId = message.setupIntentId);
679688
message.usageLimit !== undefined && (obj.usageLimit = Math.round(message.usageLimit));
680689
return obj;
@@ -683,6 +692,7 @@ export const CreateStripeSubscriptionRequest = {
683692
fromPartial(object: DeepPartial<CreateStripeSubscriptionRequest>): CreateStripeSubscriptionRequest {
684693
const message = createBaseCreateStripeSubscriptionRequest();
685694
message.attributionId = object.attributionId ?? "";
695+
message.customerId = object.customerId ?? "";
686696
message.setupIntentId = object.setupIntentId ?? "";
687697
message.usageLimit = object.usageLimit ?? 0;
688698
return message;

components/usage-api/usage/v1/billing.proto

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,9 @@ message CreateStripeCustomerResponse {
7878

7979
message CreateStripeSubscriptionRequest {
8080
string attribution_id = 1;
81-
string setup_intent_id = 2;
82-
int64 usage_limit = 3;
81+
string customer_id = 2;
82+
string setup_intent_id = 3;
83+
int64 usage_limit = 4;
8384
}
8485

8586
message CreateStripeSubscriptionResponse {

components/usage/pkg/apiv1/billing.go

Lines changed: 66 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,18 +23,20 @@ import (
2323
"gorm.io/gorm"
2424
)
2525

26-
func NewBillingService(stripeClient *stripe.Client, conn *gorm.DB, ccManager *db.CostCenterManager) *BillingService {
26+
func NewBillingService(stripeClient *stripe.Client, conn *gorm.DB, ccManager *db.CostCenterManager, stripePrices stripe.StripePrices) *BillingService {
2727
return &BillingService{
2828
stripeClient: stripeClient,
2929
conn: conn,
3030
ccManager: ccManager,
31+
stripePrices: stripePrices,
3132
}
3233
}
3334

3435
type BillingService struct {
3536
conn *gorm.DB
3637
stripeClient *stripe.Client
3738
ccManager *db.CostCenterManager
39+
stripePrices stripe.StripePrices
3840

3941
v1.UnimplementedBillingServiceServer
4042
}
@@ -165,6 +167,69 @@ func (s *BillingService) CreateStripeCustomer(ctx context.Context, req *v1.Creat
165167
}, nil
166168
}
167169

170+
func (s *BillingService) CreateStripeSubscription(ctx context.Context, req *v1.CreateStripeSubscriptionRequest) (*v1.CreateStripeSubscriptionResponse, error) {
171+
attributionID, err := db.ParseAttributionID(req.GetAttributionId())
172+
if err != nil {
173+
return nil, status.Errorf(codes.InvalidArgument, "Invalid attribution ID %s", attributionID)
174+
}
175+
176+
if req.CustomerId == "" {
177+
return nil, status.Error(codes.InvalidArgument, "Invalid customer ID")
178+
}
179+
180+
_, err = s.stripeClient.SetDefaultPaymentForCustomer(ctx, req.CustomerId, req.SetupIntentId)
181+
if err != nil {
182+
return nil, status.Errorf(codes.InvalidArgument, "Failed to set default payment for customer ID %s", req.CustomerId)
183+
}
184+
185+
stripeCustomer, err := s.stripeClient.GetCustomer(ctx, req.CustomerId)
186+
if err != nil {
187+
return nil, err
188+
}
189+
190+
priceIdentifier := getPriceIdentifier(attributionID, stripeCustomer, s)
191+
if priceIdentifier == "" {
192+
return nil, status.Errorf(codes.InvalidArgument, "Invalid currency %s for customer ID %s", stripeCustomer.Currency, stripeCustomer.ID)
193+
}
194+
195+
isAutomaticTaxSupported := stripeCustomer.Tax.AutomaticTax == "supported"
196+
if !isAutomaticTaxSupported {
197+
log.Warnf("Automatic Stripe tax is not supported for customer %s", stripeCustomer.ID)
198+
}
199+
200+
subscription, err := s.stripeClient.CreateSubscription(ctx, stripeCustomer, priceIdentifier, isAutomaticTaxSupported)
201+
if err != nil {
202+
return nil, status.Errorf(codes.Aborted, "Failed to create subscription with customer ID %s", req.CustomerId)
203+
}
204+
205+
return &v1.CreateStripeSubscriptionResponse{
206+
Subscription: &v1.StripeSubscription{
207+
Id: subscription.ID,
208+
},
209+
}, nil
210+
}
211+
212+
func getPriceIdentifier(attributionID db.AttributionID, stripeCustomer *stripe_api.Customer, s *BillingService) string {
213+
priceIdentifier := ""
214+
if attributionID.IsEntity("team") {
215+
if stripeCustomer.Currency == "EUR" {
216+
priceIdentifier = s.stripePrices.TeamUsagePriceIDs.EUR
217+
}
218+
if stripeCustomer.Currency == "USD" {
219+
priceIdentifier = s.stripePrices.TeamUsagePriceIDs.USD
220+
}
221+
}
222+
if attributionID.IsEntity("user") {
223+
if stripeCustomer.Currency == "EUR" {
224+
priceIdentifier = s.stripePrices.IndividualUsagePriceIDs.EUR
225+
}
226+
if stripeCustomer.Currency == "USD" {
227+
priceIdentifier = s.stripePrices.IndividualUsagePriceIDs.USD
228+
}
229+
}
230+
return priceIdentifier
231+
}
232+
168233
func (s *BillingService) ReconcileInvoices(ctx context.Context, in *v1.ReconcileInvoicesRequest) (*v1.ReconcileInvoicesResponse, error) {
169234
balances, err := db.ListBalance(ctx, s.conn)
170235
if err != nil {

components/usage/pkg/server/server.go

Lines changed: 2 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -40,17 +40,7 @@ type Config struct {
4040
DefaultSpendingLimit db.DefaultSpendingLimit `json:"defaultSpendingLimit"`
4141

4242
// StripePrices configure which Stripe Price IDs should be used
43-
StripePrices StripePrices `json:"stripePrices"`
44-
}
45-
46-
type PriceConfig struct {
47-
EUR string `json:"eur"`
48-
USD string `json:"usd"`
49-
}
50-
51-
type StripePrices struct {
52-
IndividualUsagePriceIDs PriceConfig `json:"individualUsagePriceIds"`
53-
TeamUsagePriceIDs PriceConfig `json:"teamUsagePriceIds"`
43+
StripePrices stripe.StripePrices `json:"stripePrices"`
5444
}
5545

5646
func Start(cfg Config, version string) error {
@@ -171,7 +161,7 @@ func registerGRPCServices(srv *baseserver.Server, conn *gorm.DB, stripeClient *s
171161
if stripeClient == nil {
172162
v1.RegisterBillingServiceServer(srv.GRPC(), &apiv1.BillingServiceNoop{})
173163
} else {
174-
v1.RegisterBillingServiceServer(srv.GRPC(), apiv1.NewBillingService(stripeClient, conn, ccManager))
164+
v1.RegisterBillingServiceServer(srv.GRPC(), apiv1.NewBillingService(stripeClient, conn, ccManager, cfg.StripePrices))
175165
}
176166
return nil
177167
}

components/usage/pkg/stripe/stripe.go

Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,16 @@ type ClientConfig struct {
3737
SecretKey string `json:"secretKey"`
3838
}
3939

40+
type PriceConfig struct {
41+
EUR string `json:"eur"`
42+
USD string `json:"usd"`
43+
}
44+
45+
type StripePrices struct {
46+
IndividualUsagePriceIDs PriceConfig `json:"individualUsagePriceIds"`
47+
TeamUsagePriceIDs PriceConfig `json:"teamUsagePriceIds"`
48+
}
49+
4050
func ReadConfigFromFile(path string) (ClientConfig, error) {
4151
bytes, err := os.ReadFile(path)
4252
if err != nil {
@@ -320,6 +330,83 @@ func (c *Client) GetSubscriptionWithCustomer(ctx context.Context, subscriptionID
320330
return subscription, nil
321331
}
322332

333+
func (c *Client) CreateSubscription(ctx context.Context, customer *stripe.Customer, priceID string, isAutomaticTaxSupported bool) (*stripe.Subscription, error) {
334+
if customer.ID == "" {
335+
return nil, fmt.Errorf("no customerID specified")
336+
}
337+
if priceID == "" {
338+
return nil, fmt.Errorf("no priceID specified")
339+
}
340+
341+
startOfNextMonth := getStartOfNextMonth()
342+
343+
params := &stripe.SubscriptionParams{
344+
Customer: &customer.ID,
345+
Items: []*stripe.SubscriptionItemsParams{
346+
{
347+
Price: &priceID,
348+
},
349+
},
350+
AutomaticTax: &stripe.SubscriptionAutomaticTaxParams{
351+
Enabled: &isAutomaticTaxSupported,
352+
},
353+
BillingCycleAnchor: stripe.Int64(startOfNextMonth.Unix()),
354+
}
355+
356+
subscription, err := c.sc.Subscriptions.New(params)
357+
if err != nil {
358+
return nil, fmt.Errorf("failed to get subscription with customer ID %s", customer.ID)
359+
}
360+
361+
return subscription, err
362+
}
363+
364+
func getStartOfNextMonth() time.Time {
365+
now := time.Now()
366+
currentYear, currentMonth, _ := now.Date()
367+
368+
firstOfMonth := time.Date(currentYear, currentMonth, 1, 0, 0, 0, 0, time.UTC)
369+
startOfNextMonth := firstOfMonth.AddDate(0, 1, 0)
370+
371+
return startOfNextMonth
372+
}
373+
374+
func (c *Client) SetDefaultPaymentForCustomer(ctx context.Context, customerID string, setupIntentId string) (*stripe.Customer, error) {
375+
if customerID == "" {
376+
return nil, fmt.Errorf("no customerID specified")
377+
}
378+
379+
if setupIntentId == "" {
380+
return nil, fmt.Errorf("no setupIntentID specified")
381+
}
382+
383+
setupIntent, err := c.sc.SetupIntents.Get(setupIntentId, &stripe.SetupIntentParams{
384+
Params: stripe.Params{
385+
Context: ctx,
386+
},
387+
})
388+
if err != nil {
389+
return nil, fmt.Errorf("Failed to retrieve setup intent with id %s", setupIntentId)
390+
}
391+
392+
paymentMethod, err := c.sc.PaymentMethods.Attach(setupIntent.PaymentMethod.ID, &stripe.PaymentMethodAttachParams{Customer: &customerID})
393+
if err != nil {
394+
return nil, fmt.Errorf("Failed to attach payment method to setup intent ID %s", setupIntentId)
395+
}
396+
397+
customer, _ := c.sc.Customers.Update(customerID, &stripe.CustomerParams{
398+
InvoiceSettings: &stripe.CustomerInvoiceSettingsParams{
399+
DefaultPaymentMethod: &paymentMethod.ID},
400+
Address: &stripe.AddressParams{
401+
Line1: &paymentMethod.BillingDetails.Address.Line1,
402+
Country: &paymentMethod.BillingDetails.Address.Country}})
403+
if err != nil {
404+
return nil, fmt.Errorf("Failed to update customer with id %s", customerID)
405+
}
406+
407+
return customer, nil
408+
}
409+
323410
func GetAttributionID(ctx context.Context, customer *stripe.Customer) (db.AttributionID, error) {
324411
if customer == nil {
325412
log.Error("No customer information available for invoice.")

components/usage/pkg/stripe/stripe_test.go

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,10 @@ package stripe
66

77
import (
88
"fmt"
9-
"github.com/gitpod-io/gitpod/usage/pkg/db"
109
"testing"
10+
"time"
11+
12+
"github.com/gitpod-io/gitpod/usage/pkg/db"
1113

1214
"github.com/stretchr/testify/require"
1315
)
@@ -89,3 +91,11 @@ func TestCustomerQueriesForTeamIds_MultipleQueries(t *testing.T) {
8991
})
9092
}
9193
}
94+
95+
func TestStartOfNextMonth(t *testing.T) {
96+
nextMonth := time.Now().AddDate(0, 1, 0).Month()
97+
actualStartOfNextMonth := time.Time(time.Date(2022, nextMonth, 1, 0, 0, 0, 0, time.UTC))
98+
expectedStartOfNextMonth := getStartOfNextMonth()
99+
100+
require.Equal(t, actualStartOfNextMonth, expectedStartOfNextMonth)
101+
}

install/installer/pkg/components/usage/configmap.go

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import (
99
"github.com/gitpod-io/gitpod/common-go/baseserver"
1010
"github.com/gitpod-io/gitpod/usage/pkg/db"
1111
"github.com/gitpod-io/gitpod/usage/pkg/server"
12+
"github.com/gitpod-io/gitpod/usage/pkg/stripe"
1213

1314
"github.com/gitpod-io/gitpod/installer/pkg/common"
1415
"github.com/gitpod-io/gitpod/installer/pkg/config/v1/experimental"
@@ -37,12 +38,12 @@ func configmap(ctx *common.RenderContext) ([]runtime.Object, error) {
3738

3839
expWebAppConfig := getExperimentalWebAppConfig(ctx)
3940
if expWebAppConfig != nil && expWebAppConfig.Stripe != nil {
40-
cfg.StripePrices = server.StripePrices{
41-
IndividualUsagePriceIDs: server.PriceConfig{
41+
cfg.StripePrices = stripe.StripePrices{
42+
IndividualUsagePriceIDs: stripe.PriceConfig{
4243
EUR: expWebAppConfig.Stripe.IndividualUsagePriceIDs.EUR,
4344
USD: expWebAppConfig.Stripe.IndividualUsagePriceIDs.USD,
4445
},
45-
TeamUsagePriceIDs: server.PriceConfig{
46+
TeamUsagePriceIDs: stripe.PriceConfig{
4647
EUR: expWebAppConfig.Stripe.TeamUsagePriceIDs.EUR,
4748
USD: expWebAppConfig.Stripe.TeamUsagePriceIDs.USD,
4849
},

0 commit comments

Comments
 (0)