diff --git a/components/public-api-server/pkg/billingservice/client.go b/components/public-api-server/pkg/billingservice/client.go index 0ec3e966c9b3af..9f785d0e8adc1b 100644 --- a/components/public-api-server/pkg/billingservice/client.go +++ b/components/public-api-server/pkg/billingservice/client.go @@ -8,13 +8,14 @@ import ( "context" "fmt" - "github.com/gitpod-io/gitpod/usage-api/v1" + v1 "github.com/gitpod-io/gitpod/usage-api/v1" "google.golang.org/grpc" "google.golang.org/grpc/credentials/insecure" ) type Interface interface { FinalizeInvoice(ctx context.Context, invoiceId string) error + CancelSubscription(ctx context.Context, subscriptionId string) error } type Client struct { @@ -38,3 +39,12 @@ func (c *Client) FinalizeInvoice(ctx context.Context, invoiceId string) error { return nil } + +func (c *Client) CancelSubscription(ctx context.Context, subscriptionId string) error { + _, err := c.b.CancelSubscription(ctx, &v1.CancelSubscriptionRequest{SubscriptionId: subscriptionId}) + if err != nil { + return fmt.Errorf("failed RPC to billing service: %s", err) + } + + return nil +} diff --git a/components/public-api-server/pkg/billingservice/mock_billingservice/billingservice.go b/components/public-api-server/pkg/billingservice/mock_billingservice/billingservice.go index 8263a4ba13af29..d573f75e7027c9 100644 --- a/components/public-api-server/pkg/billingservice/mock_billingservice/billingservice.go +++ b/components/public-api-server/pkg/billingservice/mock_billingservice/billingservice.go @@ -51,3 +51,17 @@ func (mr *MockInterfaceMockRecorder) FinalizeInvoice(ctx, invoiceId interface{}) mr.mock.ctrl.T.Helper() return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "FinalizeInvoice", reflect.TypeOf((*MockInterface)(nil).FinalizeInvoice), ctx, invoiceId) } + +// CancelSubscription mocks base method. +func (m *MockInterface) CancelSubscription(ctx context.Context, subscriptionId string) error { + m.ctrl.T.Helper() + ret := m.ctrl.Call(m, "CancelSubscription", ctx, subscriptionId) + ret0, _ := ret[0].(error) + return ret0 +} + +// CancelSubscription indicates an expected call of CancelSubscription. +func (mr *MockInterfaceMockRecorder) CancelSubscription(ctx, subscriptionId interface{}) *gomock.Call { + mr.mock.ctrl.T.Helper() + return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "CancelSubscription", reflect.TypeOf((*MockInterface)(nil).CancelSubscription), ctx, subscriptionId) +} diff --git a/components/public-api-server/pkg/billingservice/noop.go b/components/public-api-server/pkg/billingservice/noop.go index f9b573ff542532..aea2c4e7201643 100644 --- a/components/public-api-server/pkg/billingservice/noop.go +++ b/components/public-api-server/pkg/billingservice/noop.go @@ -11,3 +11,7 @@ type NoOpClient struct{} func (c *NoOpClient) FinalizeInvoice(ctx context.Context, invoiceId string) error { return nil } + +func (c *NoOpClient) CancelSubscription(ctx context.Context, subscriptionId string) error { + return nil +} diff --git a/components/public-api-server/pkg/webhooks/stripe.go b/components/public-api-server/pkg/webhooks/stripe.go index 112971d7b135a1..941372518f7b68 100644 --- a/components/public-api-server/pkg/webhooks/stripe.go +++ b/components/public-api-server/pkg/webhooks/stripe.go @@ -5,11 +5,12 @@ package webhooks import ( + "io" + "net/http" + "github.com/gitpod-io/gitpod/common-go/log" "github.com/gitpod-io/gitpod/public-api-server/pkg/billingservice" "github.com/stripe/stripe-go/v72/webhook" - "io" - "net/http" ) const maxBodyBytes = int64(65536) @@ -56,22 +57,36 @@ func (h *webhookHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) { return } - if event.Type != "invoice.finalized" { + switch event.Type { + case "invoice.finalized": + invoiceId, ok := event.Data.Object["id"].(string) + if !ok { + log.Error("failed to find invoice id in Stripe event payload") + w.WriteHeader(http.StatusBadRequest) + } + + err = h.billingService.FinalizeInvoice(req.Context(), invoiceId) + if err != nil { + log.WithError(err).Error("Failed to finalize invoice") + w.WriteHeader(http.StatusInternalServerError) + return + } + case "customer.subscription.deleted": + subscriptionId, ok := event.Data.Object["id"].(string) + if !ok { + log.Error("failed to find subscriptionId id in Stripe event payload") + w.WriteHeader(http.StatusBadRequest) + } + err = h.billingService.CancelSubscription(req.Context(), subscriptionId) + if err != nil { + log.WithError(err).Error("Failed to cancel subscription") + w.WriteHeader(http.StatusInternalServerError) + return + } + default: log.Errorf("Unexpected Stripe event type: %s", event.Type) w.WriteHeader(http.StatusBadRequest) return } - invoiceId, ok := event.Data.Object["id"].(string) - if !ok { - log.Error("failed to find invoice id in Stripe event payload") - w.WriteHeader(http.StatusBadRequest) - } - - err = h.billingService.FinalizeInvoice(req.Context(), invoiceId) - if err != nil { - log.WithError(err).Error("Failed to finalize invoice") - w.WriteHeader(http.StatusInternalServerError) - return - } } diff --git a/components/public-api-server/pkg/webhooks/stripe_test.go b/components/public-api-server/pkg/webhooks/stripe_test.go index 57c49bf7130d96..fd37c8d746b864 100644 --- a/components/public-api-server/pkg/webhooks/stripe_test.go +++ b/components/public-api-server/pkg/webhooks/stripe_test.go @@ -8,11 +8,12 @@ import ( "bytes" "encoding/hex" "fmt" - "github.com/stripe/stripe-go/v72/webhook" "net/http" "testing" "time" + "github.com/stripe/stripe-go/v72/webhook" + "github.com/gitpod-io/gitpod/common-go/baseserver" "github.com/gitpod-io/gitpod/public-api-server/pkg/billingservice" mockbillingservice "github.com/gitpod-io/gitpod/public-api-server/pkg/billingservice/mock_billingservice" @@ -22,9 +23,10 @@ import ( // https://stripe.com/docs/api/events/types const ( - invoiceUpdatedEventType = "invoice.updated" - invoiceFinalizedEventType = "invoice.finalized" - customerCreatedEventType = "customer.created" + invoiceUpdatedEventType = "invoice.updated" + invoiceFinalizedEventType = "invoice.finalized" + customerCreatedEventType = "customer.created" + customerSubscriptionDeleted = "customer.subscription.deleted" ) const ( @@ -80,6 +82,10 @@ func TestWebhookIgnoresIrrelevantEvents(t *testing.T) { EventType: invoiceFinalizedEventType, ExpectedStatusCode: http.StatusOK, }, + { + EventType: customerSubscriptionDeleted, + ExpectedStatusCode: http.StatusOK, + }, { EventType: invoiceUpdatedEventType, ExpectedStatusCode: http.StatusBadRequest, @@ -134,6 +140,26 @@ func TestWebhookInvokesFinalizeInvoiceRPC(t *testing.T) { require.Equal(t, http.StatusOK, resp.StatusCode) } +func TestWebhookInvokesCancelSubscriptionRPC(t *testing.T) { + ctrl := gomock.NewController(t) + m := mockbillingservice.NewMockInterface(ctrl) + m.EXPECT().CancelSubscription(gomock.Any(), gomock.Eq("in_1LUQi7GadRXm50o36jWK7ehs")) + + srv := baseServerWithStripeWebhook(t, m) + + url := fmt.Sprintf("%s%s", srv.HTTPAddress(), "/webhook") + + payload := payloadForStripeEvent(t, customerSubscriptionDeleted) + req, err := http.NewRequest(http.MethodPost, url, bytes.NewReader(payload)) + require.NoError(t, err) + + req.Header.Set("Stripe-Signature", generateHeader(payload, testWebhookSecret)) + + resp, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, resp.StatusCode) +} + func baseServerWithStripeWebhook(t *testing.T, billingService billingservice.Interface) *baseserver.Server { t.Helper() @@ -150,19 +176,14 @@ func baseServerWithStripeWebhook(t *testing.T, billingService billingservice.Int func payloadForStripeEvent(t *testing.T, eventType string) []byte { t.Helper() - if eventType != invoiceFinalizedEventType { - return []byte(`{}`) - } - return []byte(` -{ - "data": { - "object": { - "id": "in_1LUQi7GadRXm50o36jWK7ehs" - } - }, - "type": "invoice.finalized" -} -`) + return []byte(`{ + "data": { + "object": { + "id": "in_1LUQi7GadRXm50o36jWK7ehs" + } + }, + "type": "` + eventType + `" + }`) } func generateHeader(payload []byte, secret string) string { diff --git a/components/usage-api/go/v1/billing.pb.go b/components/usage-api/go/v1/billing.pb.go index a6e69e752e8727..ed6a0e8619757c 100644 --- a/components/usage-api/go/v1/billing.pb.go +++ b/components/usage-api/go/v1/billing.pb.go @@ -387,6 +387,91 @@ func (*FinalizeInvoiceResponse) Descriptor() ([]byte, []int) { return file_usage_v1_billing_proto_rawDescGZIP(), []int{5} } +type CancelSubscriptionRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + SubscriptionId string `protobuf:"bytes,1,opt,name=subscription_id,json=subscriptionId,proto3" json:"subscription_id,omitempty"` +} + +func (x *CancelSubscriptionRequest) Reset() { + *x = CancelSubscriptionRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_usage_v1_billing_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CancelSubscriptionRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelSubscriptionRequest) ProtoMessage() {} + +func (x *CancelSubscriptionRequest) ProtoReflect() protoreflect.Message { + mi := &file_usage_v1_billing_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelSubscriptionRequest.ProtoReflect.Descriptor instead. +func (*CancelSubscriptionRequest) Descriptor() ([]byte, []int) { + return file_usage_v1_billing_proto_rawDescGZIP(), []int{6} +} + +func (x *CancelSubscriptionRequest) GetSubscriptionId() string { + if x != nil { + return x.SubscriptionId + } + return "" +} + +type CancelSubscriptionResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields +} + +func (x *CancelSubscriptionResponse) Reset() { + *x = CancelSubscriptionResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_usage_v1_billing_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CancelSubscriptionResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelSubscriptionResponse) ProtoMessage() {} + +func (x *CancelSubscriptionResponse) ProtoReflect() protoreflect.Message { + mi := &file_usage_v1_billing_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelSubscriptionResponse.ProtoReflect.Descriptor instead. +func (*CancelSubscriptionResponse) Descriptor() ([]byte, []int) { + return file_usage_v1_billing_proto_rawDescGZIP(), []int{7} +} + // If there are two billable sessions for this instance ID, // the second one's "from" will be the first one's "to" type SetBilledSessionRequest struct { @@ -402,7 +487,7 @@ type SetBilledSessionRequest struct { func (x *SetBilledSessionRequest) Reset() { *x = SetBilledSessionRequest{} if protoimpl.UnsafeEnabled { - mi := &file_usage_v1_billing_proto_msgTypes[6] + mi := &file_usage_v1_billing_proto_msgTypes[8] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -415,7 +500,7 @@ func (x *SetBilledSessionRequest) String() string { func (*SetBilledSessionRequest) ProtoMessage() {} func (x *SetBilledSessionRequest) ProtoReflect() protoreflect.Message { - mi := &file_usage_v1_billing_proto_msgTypes[6] + mi := &file_usage_v1_billing_proto_msgTypes[8] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -428,7 +513,7 @@ func (x *SetBilledSessionRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use SetBilledSessionRequest.ProtoReflect.Descriptor instead. func (*SetBilledSessionRequest) Descriptor() ([]byte, []int) { - return file_usage_v1_billing_proto_rawDescGZIP(), []int{6} + return file_usage_v1_billing_proto_rawDescGZIP(), []int{8} } func (x *SetBilledSessionRequest) GetInstanceId() string { @@ -461,7 +546,7 @@ type SetBilledSessionResponse struct { func (x *SetBilledSessionResponse) Reset() { *x = SetBilledSessionResponse{} if protoimpl.UnsafeEnabled { - mi := &file_usage_v1_billing_proto_msgTypes[7] + mi := &file_usage_v1_billing_proto_msgTypes[9] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -474,7 +559,7 @@ func (x *SetBilledSessionResponse) String() string { func (*SetBilledSessionResponse) ProtoMessage() {} func (x *SetBilledSessionResponse) ProtoReflect() protoreflect.Message { - mi := &file_usage_v1_billing_proto_msgTypes[7] + mi := &file_usage_v1_billing_proto_msgTypes[9] if protoimpl.UnsafeEnabled && x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -487,7 +572,7 @@ func (x *SetBilledSessionResponse) ProtoReflect() protoreflect.Message { // Deprecated: Use SetBilledSessionResponse.ProtoReflect.Descriptor instead. func (*SetBilledSessionResponse) Descriptor() ([]byte, []int) { - return file_usage_v1_billing_proto_rawDescGZIP(), []int{7} + return file_usage_v1_billing_proto_rawDescGZIP(), []int{9} } var File_usage_v1_billing_proto protoreflect.FileDescriptor @@ -520,51 +605,63 @@ var file_usage_v1_billing_proto_rawDesc = []byte{ 0x65, 0x73, 0x74, 0x12, 0x1d, 0x0a, 0x0a, 0x69, 0x6e, 0x76, 0x6f, 0x69, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x69, 0x6e, 0x76, 0x6f, 0x69, 0x63, 0x65, 0x49, 0x64, 0x22, 0x19, 0x0a, 0x17, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x49, 0x6e, - 0x76, 0x6f, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x94, 0x01, - 0x0a, 0x17, 0x53, 0x65, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x65, 0x64, 0x53, 0x65, 0x73, 0x73, 0x69, - 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, 0x0b, 0x69, 0x6e, 0x73, - 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, - 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x12, 0x2e, 0x0a, 0x04, 0x66, 0x72, - 0x6f, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, - 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, - 0x74, 0x61, 0x6d, 0x70, 0x52, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x12, 0x28, 0x0a, 0x06, 0x73, 0x79, - 0x73, 0x74, 0x65, 0x6d, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x10, 0x2e, 0x75, 0x73, 0x61, - 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x52, 0x06, 0x73, 0x79, - 0x73, 0x74, 0x65, 0x6d, 0x22, 0x1a, 0x0a, 0x18, 0x53, 0x65, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x65, - 0x64, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, - 0x2a, 0x45, 0x0a, 0x06, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12, 0x12, 0x0a, 0x0e, 0x53, 0x59, - 0x53, 0x54, 0x45, 0x4d, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, 0x10, 0x00, 0x12, 0x14, - 0x0a, 0x10, 0x53, 0x59, 0x53, 0x54, 0x45, 0x4d, 0x5f, 0x43, 0x48, 0x41, 0x52, 0x47, 0x45, 0x42, - 0x45, 0x45, 0x10, 0x01, 0x12, 0x11, 0x0a, 0x0d, 0x53, 0x59, 0x53, 0x54, 0x45, 0x4d, 0x5f, 0x53, - 0x54, 0x52, 0x49, 0x50, 0x45, 0x10, 0x02, 0x32, 0x8a, 0x03, 0x0a, 0x0e, 0x42, 0x69, 0x6c, 0x6c, - 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5e, 0x0a, 0x11, 0x52, 0x65, - 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x49, 0x6e, 0x76, 0x6f, 0x69, 0x63, 0x65, 0x73, 0x12, - 0x22, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x6e, - 0x63, 0x69, 0x6c, 0x65, 0x49, 0x6e, 0x76, 0x6f, 0x69, 0x63, 0x65, 0x73, 0x52, 0x65, 0x71, 0x75, - 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x52, + 0x76, 0x6f, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x44, 0x0a, + 0x19, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x27, 0x0a, 0x0f, 0x73, 0x75, + 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0e, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, + 0x6e, 0x49, 0x64, 0x22, 0x1c, 0x0a, 0x1a, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x53, 0x75, 0x62, + 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, + 0x65, 0x22, 0x94, 0x01, 0x0a, 0x17, 0x53, 0x65, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x65, 0x64, 0x53, + 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x1f, 0x0a, + 0x0b, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x0a, 0x69, 0x6e, 0x73, 0x74, 0x61, 0x6e, 0x63, 0x65, 0x49, 0x64, 0x12, 0x2e, + 0x0a, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, + 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x04, 0x66, 0x72, 0x6f, 0x6d, 0x12, 0x28, + 0x0a, 0x06, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x10, + 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, + 0x52, 0x06, 0x73, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x22, 0x1a, 0x0a, 0x18, 0x53, 0x65, 0x74, 0x42, + 0x69, 0x6c, 0x6c, 0x65, 0x64, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, + 0x6f, 0x6e, 0x73, 0x65, 0x2a, 0x45, 0x0a, 0x06, 0x53, 0x79, 0x73, 0x74, 0x65, 0x6d, 0x12, 0x12, + 0x0a, 0x0e, 0x53, 0x59, 0x53, 0x54, 0x45, 0x4d, 0x5f, 0x55, 0x4e, 0x4b, 0x4e, 0x4f, 0x57, 0x4e, + 0x10, 0x00, 0x12, 0x14, 0x0a, 0x10, 0x53, 0x59, 0x53, 0x54, 0x45, 0x4d, 0x5f, 0x43, 0x48, 0x41, + 0x52, 0x47, 0x45, 0x42, 0x45, 0x45, 0x10, 0x01, 0x12, 0x11, 0x0a, 0x0d, 0x53, 0x59, 0x53, 0x54, + 0x45, 0x4d, 0x5f, 0x53, 0x54, 0x52, 0x49, 0x50, 0x45, 0x10, 0x02, 0x32, 0xed, 0x03, 0x0a, 0x0e, + 0x42, 0x69, 0x6c, 0x6c, 0x69, 0x6e, 0x67, 0x53, 0x65, 0x72, 0x76, 0x69, 0x63, 0x65, 0x12, 0x5e, + 0x0a, 0x11, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x49, 0x6e, 0x76, 0x6f, 0x69, + 0x63, 0x65, 0x73, 0x12, 0x22, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x49, 0x6e, 0x76, 0x6f, 0x69, 0x63, 0x65, 0x73, - 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x61, 0x0a, 0x12, 0x47, 0x65, - 0x74, 0x55, 0x70, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x49, 0x6e, 0x76, 0x6f, 0x69, 0x63, 0x65, - 0x12, 0x23, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, - 0x70, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x49, 0x6e, 0x76, 0x6f, 0x69, 0x63, 0x65, 0x52, 0x65, - 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, - 0x2e, 0x47, 0x65, 0x74, 0x55, 0x70, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x49, 0x6e, 0x76, 0x6f, - 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x58, 0x0a, - 0x0f, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x49, 0x6e, 0x76, 0x6f, 0x69, 0x63, 0x65, - 0x12, 0x20, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x69, 0x6e, 0x61, - 0x6c, 0x69, 0x7a, 0x65, 0x49, 0x6e, 0x76, 0x6f, 0x69, 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, - 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x46, 0x69, - 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x49, 0x6e, 0x76, 0x6f, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, - 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x5b, 0x0a, 0x10, 0x53, 0x65, 0x74, 0x42, 0x69, - 0x6c, 0x6c, 0x65, 0x64, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x21, 0x2e, 0x75, 0x73, - 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x65, 0x64, - 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, - 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x74, 0x42, 0x69, 0x6c, - 0x6c, 0x65, 0x64, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, - 0x73, 0x65, 0x22, 0x00, 0x42, 0x2a, 0x5a, 0x28, 0x67, 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, - 0x6f, 0x6d, 0x2f, 0x67, 0x69, 0x74, 0x70, 0x6f, 0x64, 0x2d, 0x69, 0x6f, 0x2f, 0x67, 0x69, 0x74, - 0x70, 0x6f, 0x64, 0x2f, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2d, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, - 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x23, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, + 0x76, 0x31, 0x2e, 0x52, 0x65, 0x63, 0x6f, 0x6e, 0x63, 0x69, 0x6c, 0x65, 0x49, 0x6e, 0x76, 0x6f, + 0x69, 0x63, 0x65, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x61, + 0x0a, 0x12, 0x47, 0x65, 0x74, 0x55, 0x70, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x49, 0x6e, 0x76, + 0x6f, 0x69, 0x63, 0x65, 0x12, 0x23, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, + 0x47, 0x65, 0x74, 0x55, 0x70, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, 0x49, 0x6e, 0x76, 0x6f, 0x69, + 0x63, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x75, 0x73, 0x61, 0x67, + 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x55, 0x70, 0x63, 0x6f, 0x6d, 0x69, 0x6e, 0x67, + 0x49, 0x6e, 0x76, 0x6f, 0x69, 0x63, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0x00, 0x12, 0x58, 0x0a, 0x0f, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x49, 0x6e, 0x76, + 0x6f, 0x69, 0x63, 0x65, 0x12, 0x20, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, + 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x49, 0x6e, 0x76, 0x6f, 0x69, 0x63, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x21, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, + 0x31, 0x2e, 0x46, 0x69, 0x6e, 0x61, 0x6c, 0x69, 0x7a, 0x65, 0x49, 0x6e, 0x76, 0x6f, 0x69, 0x63, + 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x61, 0x0a, 0x12, 0x43, + 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, + 0x6e, 0x12, 0x23, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x61, 0x6e, + 0x63, 0x65, 0x6c, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x24, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, + 0x31, 0x2e, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x70, + 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x12, 0x5b, + 0x0a, 0x10, 0x53, 0x65, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x65, 0x64, 0x53, 0x65, 0x73, 0x73, 0x69, + 0x6f, 0x6e, 0x12, 0x21, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, + 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x65, 0x64, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, 0x6e, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x22, 0x2e, 0x75, 0x73, 0x61, 0x67, 0x65, 0x2e, 0x76, 0x31, + 0x2e, 0x53, 0x65, 0x74, 0x42, 0x69, 0x6c, 0x6c, 0x65, 0x64, 0x53, 0x65, 0x73, 0x73, 0x69, 0x6f, + 0x6e, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x00, 0x42, 0x2a, 0x5a, 0x28, 0x67, + 0x69, 0x74, 0x68, 0x75, 0x62, 0x2e, 0x63, 0x6f, 0x6d, 0x2f, 0x67, 0x69, 0x74, 0x70, 0x6f, 0x64, + 0x2d, 0x69, 0x6f, 0x2f, 0x67, 0x69, 0x74, 0x70, 0x6f, 0x64, 0x2f, 0x75, 0x73, 0x61, 0x67, 0x65, + 0x2d, 0x61, 0x70, 0x69, 0x2f, 0x76, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, } var ( @@ -580,7 +677,7 @@ func file_usage_v1_billing_proto_rawDescGZIP() []byte { } var file_usage_v1_billing_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_usage_v1_billing_proto_msgTypes = make([]protoimpl.MessageInfo, 8) +var file_usage_v1_billing_proto_msgTypes = make([]protoimpl.MessageInfo, 10) var file_usage_v1_billing_proto_goTypes = []interface{}{ (System)(0), // 0: usage.v1.System (*ReconcileInvoicesRequest)(nil), // 1: usage.v1.ReconcileInvoicesRequest @@ -589,26 +686,30 @@ var file_usage_v1_billing_proto_goTypes = []interface{}{ (*GetUpcomingInvoiceResponse)(nil), // 4: usage.v1.GetUpcomingInvoiceResponse (*FinalizeInvoiceRequest)(nil), // 5: usage.v1.FinalizeInvoiceRequest (*FinalizeInvoiceResponse)(nil), // 6: usage.v1.FinalizeInvoiceResponse - (*SetBilledSessionRequest)(nil), // 7: usage.v1.SetBilledSessionRequest - (*SetBilledSessionResponse)(nil), // 8: usage.v1.SetBilledSessionResponse - (*timestamppb.Timestamp)(nil), // 9: google.protobuf.Timestamp + (*CancelSubscriptionRequest)(nil), // 7: usage.v1.CancelSubscriptionRequest + (*CancelSubscriptionResponse)(nil), // 8: usage.v1.CancelSubscriptionResponse + (*SetBilledSessionRequest)(nil), // 9: usage.v1.SetBilledSessionRequest + (*SetBilledSessionResponse)(nil), // 10: usage.v1.SetBilledSessionResponse + (*timestamppb.Timestamp)(nil), // 11: google.protobuf.Timestamp } var file_usage_v1_billing_proto_depIdxs = []int32{ - 9, // 0: usage.v1.SetBilledSessionRequest.from:type_name -> google.protobuf.Timestamp - 0, // 1: usage.v1.SetBilledSessionRequest.system:type_name -> usage.v1.System - 1, // 2: usage.v1.BillingService.ReconcileInvoices:input_type -> usage.v1.ReconcileInvoicesRequest - 3, // 3: usage.v1.BillingService.GetUpcomingInvoice:input_type -> usage.v1.GetUpcomingInvoiceRequest - 5, // 4: usage.v1.BillingService.FinalizeInvoice:input_type -> usage.v1.FinalizeInvoiceRequest - 7, // 5: usage.v1.BillingService.SetBilledSession:input_type -> usage.v1.SetBilledSessionRequest - 2, // 6: usage.v1.BillingService.ReconcileInvoices:output_type -> usage.v1.ReconcileInvoicesResponse - 4, // 7: usage.v1.BillingService.GetUpcomingInvoice:output_type -> usage.v1.GetUpcomingInvoiceResponse - 6, // 8: usage.v1.BillingService.FinalizeInvoice:output_type -> usage.v1.FinalizeInvoiceResponse - 8, // 9: usage.v1.BillingService.SetBilledSession:output_type -> usage.v1.SetBilledSessionResponse - 6, // [6:10] is the sub-list for method output_type - 2, // [2:6] is the sub-list for method input_type - 2, // [2:2] is the sub-list for extension type_name - 2, // [2:2] is the sub-list for extension extendee - 0, // [0:2] is the sub-list for field type_name + 11, // 0: usage.v1.SetBilledSessionRequest.from:type_name -> google.protobuf.Timestamp + 0, // 1: usage.v1.SetBilledSessionRequest.system:type_name -> usage.v1.System + 1, // 2: usage.v1.BillingService.ReconcileInvoices:input_type -> usage.v1.ReconcileInvoicesRequest + 3, // 3: usage.v1.BillingService.GetUpcomingInvoice:input_type -> usage.v1.GetUpcomingInvoiceRequest + 5, // 4: usage.v1.BillingService.FinalizeInvoice:input_type -> usage.v1.FinalizeInvoiceRequest + 7, // 5: usage.v1.BillingService.CancelSubscription:input_type -> usage.v1.CancelSubscriptionRequest + 9, // 6: usage.v1.BillingService.SetBilledSession:input_type -> usage.v1.SetBilledSessionRequest + 2, // 7: usage.v1.BillingService.ReconcileInvoices:output_type -> usage.v1.ReconcileInvoicesResponse + 4, // 8: usage.v1.BillingService.GetUpcomingInvoice:output_type -> usage.v1.GetUpcomingInvoiceResponse + 6, // 9: usage.v1.BillingService.FinalizeInvoice:output_type -> usage.v1.FinalizeInvoiceResponse + 8, // 10: usage.v1.BillingService.CancelSubscription:output_type -> usage.v1.CancelSubscriptionResponse + 10, // 11: usage.v1.BillingService.SetBilledSession:output_type -> usage.v1.SetBilledSessionResponse + 7, // [7:12] is the sub-list for method output_type + 2, // [2:7] is the sub-list for method input_type + 2, // [2:2] is the sub-list for extension type_name + 2, // [2:2] is the sub-list for extension extendee + 0, // [0:2] is the sub-list for field type_name } func init() { file_usage_v1_billing_proto_init() } @@ -690,7 +791,7 @@ func file_usage_v1_billing_proto_init() { } } file_usage_v1_billing_proto_msgTypes[6].Exporter = func(v interface{}, i int) interface{} { - switch v := v.(*SetBilledSessionRequest); i { + switch v := v.(*CancelSubscriptionRequest); i { case 0: return &v.state case 1: @@ -702,6 +803,30 @@ func file_usage_v1_billing_proto_init() { } } file_usage_v1_billing_proto_msgTypes[7].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*CancelSubscriptionResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_usage_v1_billing_proto_msgTypes[8].Exporter = func(v interface{}, i int) interface{} { + switch v := v.(*SetBilledSessionRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_usage_v1_billing_proto_msgTypes[9].Exporter = func(v interface{}, i int) interface{} { switch v := v.(*SetBilledSessionResponse); i { case 0: return &v.state @@ -724,7 +849,7 @@ func file_usage_v1_billing_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: file_usage_v1_billing_proto_rawDesc, NumEnums: 1, - NumMessages: 8, + NumMessages: 10, NumExtensions: 0, NumServices: 1, }, diff --git a/components/usage-api/go/v1/billing_grpc.pb.go b/components/usage-api/go/v1/billing_grpc.pb.go index dd3a152c583a60..346e825ce3779b 100644 --- a/components/usage-api/go/v1/billing_grpc.pb.go +++ b/components/usage-api/go/v1/billing_grpc.pb.go @@ -34,6 +34,9 @@ type BillingServiceClient interface { // FinalizeInvoice marks all sessions occurring in the given Stripe invoice as // having been invoiced. FinalizeInvoice(ctx context.Context, in *FinalizeInvoiceRequest, opts ...grpc.CallOption) (*FinalizeInvoiceResponse, error) + // CancelSubscription cancels a stripe subscription in our system + // Called by a stripe webhook + CancelSubscription(ctx context.Context, in *CancelSubscriptionRequest, opts ...grpc.CallOption) (*CancelSubscriptionResponse, error) // SetBilledSession marks an instance as billed with a billing system SetBilledSession(ctx context.Context, in *SetBilledSessionRequest, opts ...grpc.CallOption) (*SetBilledSessionResponse, error) } @@ -73,6 +76,15 @@ func (c *billingServiceClient) FinalizeInvoice(ctx context.Context, in *Finalize return out, nil } +func (c *billingServiceClient) CancelSubscription(ctx context.Context, in *CancelSubscriptionRequest, opts ...grpc.CallOption) (*CancelSubscriptionResponse, error) { + out := new(CancelSubscriptionResponse) + err := c.cc.Invoke(ctx, "/usage.v1.BillingService/CancelSubscription", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *billingServiceClient) SetBilledSession(ctx context.Context, in *SetBilledSessionRequest, opts ...grpc.CallOption) (*SetBilledSessionResponse, error) { out := new(SetBilledSessionResponse) err := c.cc.Invoke(ctx, "/usage.v1.BillingService/SetBilledSession", in, out, opts...) @@ -94,6 +106,9 @@ type BillingServiceServer interface { // FinalizeInvoice marks all sessions occurring in the given Stripe invoice as // having been invoiced. FinalizeInvoice(context.Context, *FinalizeInvoiceRequest) (*FinalizeInvoiceResponse, error) + // CancelSubscription cancels a stripe subscription in our system + // Called by a stripe webhook + CancelSubscription(context.Context, *CancelSubscriptionRequest) (*CancelSubscriptionResponse, error) // SetBilledSession marks an instance as billed with a billing system SetBilledSession(context.Context, *SetBilledSessionRequest) (*SetBilledSessionResponse, error) mustEmbedUnimplementedBillingServiceServer() @@ -112,6 +127,9 @@ func (UnimplementedBillingServiceServer) GetUpcomingInvoice(context.Context, *Ge func (UnimplementedBillingServiceServer) FinalizeInvoice(context.Context, *FinalizeInvoiceRequest) (*FinalizeInvoiceResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method FinalizeInvoice not implemented") } +func (UnimplementedBillingServiceServer) CancelSubscription(context.Context, *CancelSubscriptionRequest) (*CancelSubscriptionResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method CancelSubscription not implemented") +} func (UnimplementedBillingServiceServer) SetBilledSession(context.Context, *SetBilledSessionRequest) (*SetBilledSessionResponse, error) { return nil, status.Errorf(codes.Unimplemented, "method SetBilledSession not implemented") } @@ -182,6 +200,24 @@ func _BillingService_FinalizeInvoice_Handler(srv interface{}, ctx context.Contex return interceptor(ctx, in, info, handler) } +func _BillingService_CancelSubscription_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CancelSubscriptionRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(BillingServiceServer).CancelSubscription(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/usage.v1.BillingService/CancelSubscription", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(BillingServiceServer).CancelSubscription(ctx, req.(*CancelSubscriptionRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _BillingService_SetBilledSession_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(SetBilledSessionRequest) if err := dec(in); err != nil { @@ -219,6 +255,10 @@ var BillingService_ServiceDesc = grpc.ServiceDesc{ MethodName: "FinalizeInvoice", Handler: _BillingService_FinalizeInvoice_Handler, }, + { + MethodName: "CancelSubscription", + Handler: _BillingService_CancelSubscription_Handler, + }, { MethodName: "SetBilledSession", Handler: _BillingService_SetBilledSession_Handler, diff --git a/components/usage-api/typescript/src/usage/v1/billing.pb.ts b/components/usage-api/typescript/src/usage/v1/billing.pb.ts index 5cbcf640ee8da5..7b497facb40dad 100644 --- a/components/usage-api/typescript/src/usage/v1/billing.pb.ts +++ b/components/usage-api/typescript/src/usage/v1/billing.pb.ts @@ -90,6 +90,13 @@ export interface FinalizeInvoiceRequest { export interface FinalizeInvoiceResponse { } +export interface CancelSubscriptionRequest { + subscriptionId: string; +} + +export interface CancelSubscriptionResponse { +} + /** * If there are two billable sessions for this instance ID, * the second one's "from" will be the first one's "to" @@ -401,6 +408,92 @@ export const FinalizeInvoiceResponse = { }, }; +function createBaseCancelSubscriptionRequest(): CancelSubscriptionRequest { + return { subscriptionId: "" }; +} + +export const CancelSubscriptionRequest = { + encode(message: CancelSubscriptionRequest, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + if (message.subscriptionId !== "") { + writer.uint32(10).string(message.subscriptionId); + } + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): CancelSubscriptionRequest { + const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseCancelSubscriptionRequest(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + case 1: + message.subscriptionId = reader.string(); + break; + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }, + + fromJSON(object: any): CancelSubscriptionRequest { + return { subscriptionId: isSet(object.subscriptionId) ? String(object.subscriptionId) : "" }; + }, + + toJSON(message: CancelSubscriptionRequest): unknown { + const obj: any = {}; + message.subscriptionId !== undefined && (obj.subscriptionId = message.subscriptionId); + return obj; + }, + + fromPartial(object: DeepPartial): CancelSubscriptionRequest { + const message = createBaseCancelSubscriptionRequest(); + message.subscriptionId = object.subscriptionId ?? ""; + return message; + }, +}; + +function createBaseCancelSubscriptionResponse(): CancelSubscriptionResponse { + return {}; +} + +export const CancelSubscriptionResponse = { + encode(_: CancelSubscriptionResponse, writer: _m0.Writer = _m0.Writer.create()): _m0.Writer { + return writer; + }, + + decode(input: _m0.Reader | Uint8Array, length?: number): CancelSubscriptionResponse { + const reader = input instanceof _m0.Reader ? input : new _m0.Reader(input); + let end = length === undefined ? reader.len : reader.pos + length; + const message = createBaseCancelSubscriptionResponse(); + while (reader.pos < end) { + const tag = reader.uint32(); + switch (tag >>> 3) { + default: + reader.skipType(tag & 7); + break; + } + } + return message; + }, + + fromJSON(_: any): CancelSubscriptionResponse { + return {}; + }, + + toJSON(_: CancelSubscriptionResponse): unknown { + const obj: any = {}; + return obj; + }, + + fromPartial(_: DeepPartial): CancelSubscriptionResponse { + const message = createBaseCancelSubscriptionResponse(); + return message; + }, +}; + function createBaseSetBilledSessionRequest(): SetBilledSessionRequest { return { instanceId: "", from: undefined, system: System.SYSTEM_UNKNOWN }; } @@ -545,6 +638,18 @@ export const BillingServiceDefinition = { responseStream: false, options: {}, }, + /** + * CancelSubscription cancels a stripe subscription in our system + * Called by a stripe webhook + */ + cancelSubscription: { + name: "CancelSubscription", + requestType: CancelSubscriptionRequest, + requestStream: false, + responseType: CancelSubscriptionResponse, + responseStream: false, + options: {}, + }, /** SetBilledSession marks an instance as billed with a billing system */ setBilledSession: { name: "SetBilledSession", @@ -579,6 +684,14 @@ export interface BillingServiceServiceImplementation { request: FinalizeInvoiceRequest, context: CallContext & CallContextExt, ): Promise>; + /** + * CancelSubscription cancels a stripe subscription in our system + * Called by a stripe webhook + */ + cancelSubscription( + request: CancelSubscriptionRequest, + context: CallContext & CallContextExt, + ): Promise>; /** SetBilledSession marks an instance as billed with a billing system */ setBilledSession( request: SetBilledSessionRequest, @@ -608,6 +721,14 @@ export interface BillingServiceClient { request: DeepPartial, options?: CallOptions & CallOptionsExt, ): Promise; + /** + * CancelSubscription cancels a stripe subscription in our system + * Called by a stripe webhook + */ + cancelSubscription( + request: DeepPartial, + options?: CallOptions & CallOptionsExt, + ): Promise; /** SetBilledSession marks an instance as billed with a billing system */ setBilledSession( request: DeepPartial, diff --git a/components/usage-api/usage/v1/billing.proto b/components/usage-api/usage/v1/billing.proto index 8be6fb11de0119..6b07938042cade 100644 --- a/components/usage-api/usage/v1/billing.proto +++ b/components/usage-api/usage/v1/billing.proto @@ -18,6 +18,10 @@ service BillingService { // having been invoiced. rpc FinalizeInvoice(FinalizeInvoiceRequest) returns (FinalizeInvoiceResponse) {}; + // CancelSubscription cancels a stripe subscription in our system + // Called by a stripe webhook + rpc CancelSubscription(CancelSubscriptionRequest) returns (CancelSubscriptionResponse) {}; + // SetBilledSession marks an instance as billed with a billing system rpc SetBilledSession(SetBilledSessionRequest) returns (SetBilledSessionResponse) {}; } @@ -47,6 +51,13 @@ message FinalizeInvoiceRequest { message FinalizeInvoiceResponse { } +message CancelSubscriptionRequest { + string subscription_id = 1; +} + +message CancelSubscriptionResponse { +} + enum System { SYSTEM_UNKNOWN = 0; SYSTEM_CHARGEBEE = 1; diff --git a/components/usage/debug.sh b/components/usage/debug.sh new file mode 100755 index 00000000000000..2ba94e2e14edd6 --- /dev/null +++ b/components/usage/debug.sh @@ -0,0 +1,7 @@ +#!/bin/bash +# Copyright (c) 2022 Gitpod GmbH. All rights reserved. +# Licensed under the GNU Affero General Public License (AGPL). +# See License-AGPL.txt in the project root for license information. + +set -Eeuo pipefail +source /workspace/gitpod/scripts/ws-deploy.sh deployment usage diff --git a/components/usage/pkg/apiv1/billing.go b/components/usage/pkg/apiv1/billing.go index fe7ce866df5249..d1b12cbf907b21 100644 --- a/components/usage/pkg/apiv1/billing.go +++ b/components/usage/pkg/apiv1/billing.go @@ -22,16 +22,18 @@ import ( "gorm.io/gorm" ) -func NewBillingService(stripeClient *stripe.Client, conn *gorm.DB) *BillingService { +func NewBillingService(stripeClient *stripe.Client, conn *gorm.DB, ccManager *db.CostCenterManager) *BillingService { return &BillingService{ stripeClient: stripeClient, conn: conn, + ccManager: ccManager, } } type BillingService struct { conn *gorm.DB stripeClient *stripe.Client + ccManager *db.CostCenterManager v1.UnimplementedBillingServiceServer } @@ -43,8 +45,20 @@ func (s *BillingService) ReconcileInvoices(ctx context.Context, in *v1.Reconcile return nil, status.Errorf(codes.Internal, "Failed to reconcile invoices.") } - creditSummaryForTeams := map[db.AttributionID]int64{} + //TODO (se) make it one query + stripeBalances := []db.Balance{} for _, balance := range balances { + costCenter, err := s.ccManager.GetOrCreateCostCenter(ctx, balance.AttributionID) + if err != nil { + return nil, err + } + if costCenter.BillingStrategy == db.CostCenter_Stripe { + stripeBalances = append(stripeBalances, balance) + } + } + + creditSummaryForTeams := map[db.AttributionID]int64{} + for _, balance := range stripeBalances { creditSummaryForTeams[balance.AttributionID] = int64(math.Ceil(balance.CreditCents.ToCredits())) } @@ -70,22 +84,11 @@ func (s *BillingService) FinalizeInvoice(ctx context.Context, in *v1.FinalizeInv return nil, status.Errorf(codes.NotFound, "Failed to get invoice with ID %s: %s", in.GetInvoiceId(), err.Error()) } - customer := invoice.Customer - if customer == nil { - logger.Error("No customer information available for invoice.") - return nil, status.Errorf(codes.Internal, "Failed to retrieve customer details from invoice.") - } - logger = logger.WithField("stripe_customer", customer.ID).WithField("stripe_customer_name", customer.Name) - - teamID, found := customer.Metadata[stripe.AttributionIDMetadataKey] - if !found { - logger.Error("Failed to find teamID from subscription metadata.") - return nil, status.Errorf(codes.Internal, "Failed to extra teamID from Stripe subscription.") + attributionID, err := stripe.GetAttributionID(ctx, invoice.Customer) + if err != nil { + return nil, err } - logger = logger.WithField("team_id", teamID) - - // To support individual `user`s, we'll need to also extract the `userId` from metadata here and handle separately. - attributionID := db.NewTeamAttributionID(teamID) + logger = logger.WithField("attributionID", attributionID) finalizedAt := time.Unix(invoice.StatusTransitions.FinalizedAt, 0) logger = logger. @@ -127,6 +130,36 @@ func (s *BillingService) FinalizeInvoice(ctx context.Context, in *v1.FinalizeInv return &v1.FinalizeInvoiceResponse{}, nil } +func (s *BillingService) CancelSubscription(ctx context.Context, in *v1.CancelSubscriptionRequest) (*v1.CancelSubscriptionResponse, error) { + logger := log.WithField("subscription_id", in.GetSubscriptionId()) + logger.Infof("Subscription ended. Setting cost center back to free.") + if in.GetSubscriptionId() == "" { + return nil, status.Errorf(codes.InvalidArgument, "subscriptionId is required") + } + + subscription, err := s.stripeClient.GetSubscriptionWithCustomer(ctx, in.GetSubscriptionId()) + if err != nil { + return nil, err + } + + attributionID, err := stripe.GetAttributionID(ctx, subscription.Customer) + if err != nil { + return nil, err + } + + costCenter, err := s.ccManager.GetOrCreateCostCenter(ctx, attributionID) + if err != nil { + return nil, err + } + + costCenter.BillingStrategy = db.CostCenter_Other + _, err = s.ccManager.UpdateCostCenter(ctx, costCenter) + if err != nil { + return nil, err + } + return &v1.CancelSubscriptionResponse{}, nil +} + func (s *BillingService) GetUpcomingInvoice(ctx context.Context, in *v1.GetUpcomingInvoiceRequest) (*v1.GetUpcomingInvoiceResponse, error) { if in.GetTeamId() == "" && in.GetUserId() == "" { return nil, status.Errorf(codes.InvalidArgument, "teamId or userId is required") diff --git a/components/usage/pkg/apiv1/billing_noop.go b/components/usage/pkg/apiv1/billing_noop.go index 4d4ef59b2cce08..33874d2a668a3e 100644 --- a/components/usage/pkg/apiv1/billing_noop.go +++ b/components/usage/pkg/apiv1/billing_noop.go @@ -6,6 +6,7 @@ package apiv1 import ( "context" + "github.com/gitpod-io/gitpod/common-go/log" v1 "github.com/gitpod-io/gitpod/usage-api/v1" ) @@ -19,3 +20,8 @@ func (s *BillingServiceNoop) ReconcileInvoices(_ context.Context, _ *v1.Reconcil log.Infof("ReconcileInvoices RPC invoked in no-op mode, no invoices will be updated.") return &v1.ReconcileInvoicesResponse{}, nil } + +func (s *BillingServiceNoop) CancelSubscription(ctx context.Context, in *v1.CancelSubscriptionRequest) (*v1.CancelSubscriptionResponse, error) { + log.Infof("ReconcileInvoices RPC invoked in no-op mode, no invoices will be updated.") + return &v1.CancelSubscriptionResponse{}, nil +} diff --git a/components/usage/pkg/db/cost_center.go b/components/usage/pkg/db/cost_center.go index 7af5182e9405c6..c542ca1f8fa1a4 100644 --- a/components/usage/pkg/db/cost_center.go +++ b/components/usage/pkg/db/cost_center.go @@ -59,6 +59,7 @@ type CostCenterManager struct { // This method creates a codt center and stores it in the DB if there is no preexisting one. func (c *CostCenterManager) GetOrCreateCostCenter(ctx context.Context, attributionID AttributionID) (CostCenter, error) { logger := log.WithField("attributionId", attributionID) + logger.Info("Get or create CostCenter") result, err := getCostCenter(ctx, c.conn, attributionID) if err != nil { @@ -112,13 +113,15 @@ func (c *CostCenterManager) UpdateCostCenter(ctx context.Context, costCenter Cos now := time.Now() - // we don't allow setting the creationTime or the nextBillingTime from outside - costCenter.CreationTime = existingCostCenter.CreationTime + // we always update the creationTime + costCenter.CreationTime = NewVarcharTime(now) + // we don't allow setting the nextBillingTime from outside costCenter.NextBillingTime = existingCostCenter.NextBillingTime // Do we have a billing strategy update? if costCenter.BillingStrategy != existingCostCenter.BillingStrategy { - if existingCostCenter.BillingStrategy == CostCenter_Other { + switch costCenter.BillingStrategy { + case CostCenter_Stripe: // moving to stripe -> let's run a finalization finalizationUsage, err := c.ComputeInvoiceUsageRecord(ctx, costCenter.ID) if err != nil { @@ -130,12 +133,22 @@ func (c *CostCenterManager) UpdateCostCenter(ctx context.Context, costCenter Cos return CostCenter{}, err } } + // we don't manage stripe billing cycle + costCenter.NextBillingTime = VarcharTime{} + + case CostCenter_Other: + // cancelled from stripe reset the spending limit + if costCenter.ID.IsEntity(AttributionEntity_Team) { + costCenter.SpendingLimit = c.cfg.ForTeams + } else { + costCenter.SpendingLimit = c.cfg.ForUsers + } + // see you next month + costCenter.NextBillingTime = NewVarcharTime(now.AddDate(0, 1, 0)) } - c.updateNextBillingTime(&costCenter, now) } - // we update the creationTime - costCenter.CreationTime = NewVarcharTime(now) + log.WithField("cost_center", costCenter).Info("saving cost center.") db := c.conn.Save(&costCenter) if db.Error != nil { return CostCenter{}, fmt.Errorf("failed to save cost center for attributionID %s: %w", costCenter.ID, db.Error) @@ -163,8 +176,3 @@ func (c *CostCenterManager) ComputeInvoiceUsageRecord(ctx context.Context, attri Draft: false, }, nil } - -func (c *CostCenterManager) updateNextBillingTime(costCenter *CostCenter, now time.Time) { - nextMonth := NewVarcharTime(time.Now().AddDate(0, 1, 0)) - costCenter.NextBillingTime = nextMonth -} diff --git a/components/usage/pkg/db/cost_center_test.go b/components/usage/pkg/db/cost_center_test.go index b467e053310565..12bc6a891cf863 100644 --- a/components/usage/pkg/db/cost_center_test.go +++ b/components/usage/pkg/db/cost_center_test.go @@ -83,20 +83,28 @@ func TestCostCenterManager_UpdateCostCenter(t *testing.T) { func TestSaveCostCenterMovedToStripe(t *testing.T) { conn := dbtest.ConnectForTests(t) mnr := db.NewCostCenterManager(conn, db.DefaultSpendingLimit{ - ForTeams: 0, + ForTeams: 20, ForUsers: 500, }) team := db.NewTeamAttributionID(uuid.New().String()) cleanUp(t, conn, team) teamCC, err := mnr.GetOrCreateCostCenter(context.Background(), team) require.NoError(t, err) - require.Equal(t, int32(0), teamCC.SpendingLimit) + require.Equal(t, int32(20), teamCC.SpendingLimit) teamCC.BillingStrategy = db.CostCenter_Stripe - newTeamCC, err := mnr.UpdateCostCenter(context.Background(), teamCC) + teamCC.SpendingLimit = 400050 + teamCC, err = mnr.UpdateCostCenter(context.Background(), teamCC) + require.NoError(t, err) + require.Equal(t, db.CostCenter_Stripe, teamCC.BillingStrategy) + require.Equal(t, db.VarcharTime{}, teamCC.NextBillingTime) + require.Equal(t, int32(400050), teamCC.SpendingLimit) + + teamCC.BillingStrategy = db.CostCenter_Other + teamCC, err = mnr.UpdateCostCenter(context.Background(), teamCC) require.NoError(t, err) - require.Equal(t, db.CostCenter_Stripe, newTeamCC.BillingStrategy) - require.Equal(t, newTeamCC.CreationTime.Time().AddDate(0, 1, 0).Truncate(time.Second), newTeamCC.NextBillingTime.Time().Truncate(time.Second)) + require.Equal(t, teamCC.CreationTime.Time().AddDate(0, 1, 0).Truncate(time.Second), teamCC.NextBillingTime.Time().Truncate(time.Second)) + require.Equal(t, int32(20), teamCC.SpendingLimit) } func cleanUp(t *testing.T, conn *gorm.DB, attributionIds ...db.AttributionID) { diff --git a/components/usage/pkg/db/workspace_instance.go b/components/usage/pkg/db/workspace_instance.go index dbee2c163294f0..f622057581aa35 100644 --- a/components/usage/pkg/db/workspace_instance.go +++ b/components/usage/pkg/db/workspace_instance.go @@ -187,6 +187,10 @@ func ParseAttributionID(s string) (AttributionID, error) { if len(tokens) != 2 { return "", fmt.Errorf("attribution ID (%s) does not have two parts", s) } + _, err := uuid.Parse(tokens[1]) + if err != nil { + return "", fmt.Errorf("The uuid part of attribution ID (%s) is not a valid UUID. %w", tokens[1], err) + } switch tokens[0] { case AttributionEntity_Team: diff --git a/components/usage/pkg/server/server.go b/components/usage/pkg/server/server.go index 91c50ea8681e5b..f295b9c0c547ed 100644 --- a/components/usage/pkg/server/server.go +++ b/components/usage/pkg/server/server.go @@ -158,7 +158,7 @@ func registerGRPCServices(srv *baseserver.Server, conn *gorm.DB, stripeClient *s if stripeClient == nil { v1.RegisterBillingServiceServer(srv.GRPC(), &apiv1.BillingServiceNoop{}) } else { - v1.RegisterBillingServiceServer(srv.GRPC(), apiv1.NewBillingService(stripeClient, conn)) + v1.RegisterBillingServiceServer(srv.GRPC(), apiv1.NewBillingService(stripeClient, conn, ccManager)) } return nil } diff --git a/components/usage/pkg/stripe/stripe.go b/components/usage/pkg/stripe/stripe.go index 12afd66362f747..655a0d7478f63c 100644 --- a/components/usage/pkg/stripe/stripe.go +++ b/components/usage/pkg/stripe/stripe.go @@ -8,10 +8,13 @@ import ( "context" "encoding/json" "fmt" - "github.com/gitpod-io/gitpod/usage/pkg/db" "os" "strings" + "github.com/gitpod-io/gitpod/usage/pkg/db" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" + "github.com/gitpod-io/gitpod/common-go/log" "github.com/stripe/stripe-go/v72" "github.com/stripe/stripe-go/v72/client" @@ -244,6 +247,30 @@ func (c *Client) GetInvoiceWithCustomer(ctx context.Context, invoiceID string) ( return invoice, nil } +func (c *Client) GetSubscriptionWithCustomer(ctx context.Context, subscriptionID string) (*stripe.Subscription, error) { + if subscriptionID == "" { + return nil, fmt.Errorf("no subscriptionID specified") + } + + subscription, err := c.sc.Subscriptions.Get(subscriptionID, &stripe.SubscriptionParams{ + Params: stripe.Params{ + Expand: []*string{stripe.String("customer")}, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to get subscription %s: %w", subscriptionID, err) + } + return subscription, nil +} + +func GetAttributionID(ctx context.Context, customer *stripe.Customer) (db.AttributionID, error) { + if customer == nil { + log.Error("No customer information available for invoice.") + return "", status.Errorf(codes.Internal, "Failed to retrieve customer details from invoice.") + } + return db.ParseAttributionID(customer.Metadata[AttributionIDMetadataKey]) +} + // queriesForCustomersWithAttributionIDs constructs Stripe query strings to find the Stripe Customer for each teamId // It returns multiple queries, each being a big disjunction of subclauses so that we can process multiple teamIds in one query. // `clausesPerQuery` is a limit enforced by the Stripe API. diff --git a/gitpod-ws.code-workspace b/gitpod-ws.code-workspace index 915da403795dee..1449952597007a 100644 --- a/gitpod-ws.code-workspace +++ b/gitpod-ws.code-workspace @@ -23,6 +23,7 @@ { "path": "components/ws-daemon" }, { "path": "components/ws-manager" }, { "path": "components/ws-proxy" }, + { "path": "components/public-api-server" }, { "path": "test" }, { "path": "dev/blowtorch" }, { "path": "dev/changelog" },