Path: blob/main/components/usage/pkg/apiv1/billing_test.go
2499 views
// Copyright (c) 2022 Gitpod GmbH. All rights reserved.1// Licensed under the GNU Affero General Public License (AGPL).2// See License.AGPL.txt in the project root for license information.34package apiv156import (7"context"8"encoding/json"9"fmt"10"testing"1112"github.com/bufbuild/connect-go"13db "github.com/gitpod-io/gitpod/components/gitpod-db/go"14"github.com/gitpod-io/gitpod/components/gitpod-db/go/dbtest"15experimental_v1 "github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1"16"github.com/gitpod-io/gitpod/components/public-api/go/experimental/v1/v1connect"17v1 "github.com/gitpod-io/gitpod/usage-api/v1"18"github.com/gitpod-io/gitpod/usage/pkg/stripe"19"github.com/google/uuid"20"github.com/stretchr/testify/require"21stripe_api "github.com/stripe/stripe-go/v72"22"gopkg.in/dnaeon/go-vcr.v3/cassette"23"gopkg.in/dnaeon/go-vcr.v3/recorder"24)2526func TestBillingService_OnChargeDispute(t *testing.T) {27r := NewStripeRecorder(t, "stripe_on_charge_dispute")2829client := r.GetDefaultClient()30stripeClient, err := stripe.NewWithHTTPClient(stripe.ClientConfig{31SecretKey: "testkey",32}, client)33require.NoError(t, err)3435stubUserService := &StubUserService{}36svc := &BillingService{37stripeClient: stripeClient,38teamsService: &StubTeamsService{},39userService: stubUserService,40}4142_, err = svc.OnChargeDispute(context.Background(), &v1.OnChargeDisputeRequest{43DisputeId: "dp_1MrLJpAyBDPbWrhawbWHEIDL",44})45require.NoError(t, err)4647require.Equal(t, stubUserService.blockedUsers, []string{"owner_id"})48}4950func NewStripeRecorder(t *testing.T, name string) *recorder.Recorder {51t.Helper()5253r, err := recorder.New(fmt.Sprintf("fixtures/%s", name))54require.NoError(t, err)5556t.Cleanup(func() {57err = r.Stop()58})5960// Add a hook which removes Authorization headers from all requests61hook := func(i *cassette.Interaction) error {62delete(i.Request.Headers, "Authorization")63return nil64}65r.AddHook(hook, recorder.AfterCaptureHook)6667if r.Mode() != recorder.ModeRecordOnce {68require.Fail(t, "Recorder should be in ModeRecordOnce")69}7071return r72}7374type StubTeamsService struct {75v1connect.TeamsServiceClient76}7778func (s *StubTeamsService) CreateTeam(context.Context, *connect.Request[experimental_v1.CreateTeamRequest]) (*connect.Response[experimental_v1.CreateTeamResponse], error) {79return nil, nil80}8182func (s *StubTeamsService) GetTeam(ctx context.Context, req *connect.Request[experimental_v1.GetTeamRequest]) (*connect.Response[experimental_v1.GetTeamResponse], error) {83// generate a stub which returns a team84team := &experimental_v1.Team{85Id: req.Msg.GetTeamId(),86Members: []*experimental_v1.TeamMember{87{88UserId: "owner_id",89Role: experimental_v1.TeamRole_TEAM_ROLE_OWNER,90},91{92UserId: "non_owner_id",93Role: experimental_v1.TeamRole_TEAM_ROLE_MEMBER,94},95},96}9798return connect.NewResponse(&experimental_v1.GetTeamResponse{99Team: team,100}), nil101}102103func (s *StubTeamsService) ListTeams(context.Context, *connect.Request[experimental_v1.ListTeamsRequest]) (*connect.Response[experimental_v1.ListTeamsResponse], error) {104return nil, nil105}106func (s *StubTeamsService) DeleteTeam(context.Context, *connect.Request[experimental_v1.DeleteTeamRequest]) (*connect.Response[experimental_v1.DeleteTeamResponse], error) {107return nil, nil108}109func (s *StubTeamsService) JoinTeam(context.Context, *connect.Request[experimental_v1.JoinTeamRequest]) (*connect.Response[experimental_v1.JoinTeamResponse], error) {110return nil, nil111}112func (s *StubTeamsService) ResetTeamInvitation(context.Context, *connect.Request[experimental_v1.ResetTeamInvitationRequest]) (*connect.Response[experimental_v1.ResetTeamInvitationResponse], error) {113return nil, nil114}115func (s *StubTeamsService) UpdateTeamMember(context.Context, *connect.Request[experimental_v1.UpdateTeamMemberRequest]) (*connect.Response[experimental_v1.UpdateTeamMemberResponse], error) {116return nil, nil117}118func (s *StubTeamsService) DeleteTeamMember(context.Context, *connect.Request[experimental_v1.DeleteTeamMemberRequest]) (*connect.Response[experimental_v1.DeleteTeamMemberResponse], error) {119return nil, nil120}121122type StubUserService struct {123blockedUsers []string124}125126func (s *StubUserService) GetAuthenticatedUser(context.Context, *connect.Request[experimental_v1.GetAuthenticatedUserRequest]) (*connect.Response[experimental_v1.GetAuthenticatedUserResponse], error) {127return nil, nil128}129130// ListSSHKeys lists the public SSH keys.131func (s *StubUserService) ListSSHKeys(context.Context, *connect.Request[experimental_v1.ListSSHKeysRequest]) (*connect.Response[experimental_v1.ListSSHKeysResponse], error) {132return nil, nil133}134135// CreateSSHKey adds a public SSH key.136func (s *StubUserService) CreateSSHKey(context.Context, *connect.Request[experimental_v1.CreateSSHKeyRequest]) (*connect.Response[experimental_v1.CreateSSHKeyResponse], error) {137return nil, nil138}139140// GetSSHKey retrieves an ssh key by ID.141func (s *StubUserService) GetSSHKey(context.Context, *connect.Request[experimental_v1.GetSSHKeyRequest]) (*connect.Response[experimental_v1.GetSSHKeyResponse], error) {142return nil, nil143}144145// DeleteSSHKey removes a public SSH key.146func (s *StubUserService) DeleteSSHKey(context.Context, *connect.Request[experimental_v1.DeleteSSHKeyRequest]) (*connect.Response[experimental_v1.DeleteSSHKeyResponse], error) {147return nil, nil148}149func (s *StubUserService) GetGitToken(context.Context, *connect.Request[experimental_v1.GetGitTokenRequest]) (*connect.Response[experimental_v1.GetGitTokenResponse], error) {150return nil, nil151}152func (s *StubUserService) BlockUser(ctx context.Context, req *connect.Request[experimental_v1.BlockUserRequest]) (*connect.Response[experimental_v1.BlockUserResponse], error) {153s.blockedUsers = append(s.blockedUsers, req.Msg.GetUserId())154return connect.NewResponse(&experimental_v1.BlockUserResponse{}), nil155}156157func TestBalancesForStripeCostCenters(t *testing.T) {158attributionIDForStripe := db.NewTeamAttributionID(uuid.New().String())159attributionIDForOther := db.NewTeamAttributionID(uuid.New().String())160dbconn := dbtest.ConnectForTests(t)161162dbtest.CreateCostCenters(t, dbconn,163dbtest.NewCostCenter(t, db.CostCenter{164ID: attributionIDForStripe,165BillingStrategy: db.CostCenter_Stripe,166}),167dbtest.NewCostCenter(t, db.CostCenter{168ID: attributionIDForOther,169BillingStrategy: db.CostCenter_Other,170}),171)172173balances := []db.Balance{174{175AttributionID: attributionIDForStripe,176CreditCents: 100,177},178{179AttributionID: attributionIDForOther,180CreditCents: 100,181},182}183184stripeBalances, err := balancesForStripeCostCenters(context.Background(), db.NewCostCenterManager(dbconn, db.DefaultSpendingLimit{}), balances)185require.NoError(t, err)186require.Len(t, stripeBalances, 1)187require.Equal(t, stripeBalances[0].AttributionID, attributionIDForStripe)188}189190func TestFinalizeInvoiceForIndividual(t *testing.T) {191invoice := stripe_api.Invoice{}192require.NoError(t, json.Unmarshal([]byte(IndiInvoiceTestData), &invoice))193usage, err := InternalComputeInvoiceUsage(context.Background(), &invoice, invoice.Customer)194require.NoError(t, err)195require.Equal(t, usage.CreditCents, db.CreditCents(-103100))196}197198var IndiInvoiceTestData = `{199"id": "in_1MA0RBAyBDPbWrhabNdJIuhl",200"object": "invoice",201"account_country": "DE",202"account_name": "Gitpod GmbH",203"account_tax_ids": null,204"amount_due": 1012,205"amount_paid": 1012,206"amount_remaining": 0,207"application": null,208"application_fee_amount": null,209"attempt_count": 1,210"attempted": true,211"auto_advance": false,212"automatic_tax": {213"enabled": false,214"status": null215},216"billing_reason": "subscription_cycle",217"charge": "ch_3MA1YjAyBDPbWrha1F1mqyQs",218"collection_method": "charge_automatically",219"created": 1669853737,220"currency": "usd",221"custom_fields": null,222"customer": {223"id": "cus_MoA9ghwDcE2vaA",224"object": "customer",225"address": {226"city": null,227"country": "TW",228"line1": "",229"line2": null,230"postal_code": null,231"state": null232},233"balance": 0,234"created": 1668552088,235"currency": "usd",236"default_currency": "usd",237"default_source": null,238"delinquent": false,239"description": null,240"discount": null,241"email": "[email protected]",242"invoice_prefix": "89796AD3",243"invoice_settings": {244"custom_fields": null,245"default_payment_method": "pm_1M4XoqAyBDPbWrhaqEc29Ev0",246"footer": null,247"rendering_options": null248},249"livemode": false,250"metadata": {251"attributionId": "team:12345678-1234-1234-1234-123456789abc",252"preferredCurrency": "USD"253},254"name": "user-name",255"phone": null,256"preferred_locales": [],257"shipping": null,258"tax_exempt": "none",259"test_clock": null260},261"customer_address": {262"city": null,263"country": "TW",264"line1": "",265"line2": null,266"postal_code": null,267"state": null268},269"customer_email": "[email protected]",270"customer_name": "user-name",271"customer_phone": null,272"customer_shipping": null,273"customer_tax_exempt": "none",274"customer_tax_ids": [],275"default_payment_method": null,276"default_source": null,277"default_tax_rates": [],278"description": null,279"discount": null,280"discounts": [],281"due_date": null,282"ending_balance": 0,283"footer": null,284"from_invoice": null,285"hosted_invoice_url": "xxxx",286"invoice_pdf": "xxxx",287"last_finalization_error": null,288"latest_revision": null,289"lines": {290"object": "list",291"data": [292{293"id": "il_1MA0RBAyBDPbWrhaMKGOYdcr",294"object": "line_item",295"amount": 0,296"amount_excluding_tax": 0,297"currency": "usd",298"description": "1000 credit × Gitpod Usage (Tier 1 at $0.00 / month)",299"discount_amounts": [],300"discountable": true,301"discounts": [],302"livemode": false,303"metadata": {},304"period": {305"end": 1669852800,306"start": 1668552093307},308"plan": {309"id": "price_1LmYDQAyBDPbWrhaiebWlzVX",310"object": "plan",311"active": true,312"aggregate_usage": "last_during_period",313"amount": null,314"amount_decimal": null,315"billing_scheme": "tiered",316"created": 1664263708,317"currency": "usd",318"interval": "month",319"interval_count": 1,320"livemode": false,321"metadata": {},322"nickname": "Individual USD",323"product": "prod_MIUT2nUscrEWBA",324"tiers_mode": "graduated",325"transform_usage": null,326"trial_period_days": null,327"usage_type": "metered"328},329"price": {330"id": "price_1LmYDQAyBDPbWrhaiebWlzVX",331"object": "price",332"active": true,333"billing_scheme": "tiered",334"created": 1664263708,335"currency": "usd",336"custom_unit_amount": null,337"livemode": false,338"lookup_key": null,339"metadata": {},340"nickname": "Individual USD",341"product": "prod_MIUT2nUscrEWBA",342"recurring": {343"aggregate_usage": "last_during_period",344"interval": "month",345"interval_count": 1,346"trial_period_days": null,347"usage_type": "metered"348},349"tax_behavior": "inclusive",350"tiers_mode": "graduated",351"transform_quantity": null,352"type": "recurring",353"unit_amount": null,354"unit_amount_decimal": null355},356"proration": false,357"proration_details": {358"credited_items": null359},360"quantity": 1000,361"subscription": "sub_1M4XovAyBDPbWrhaCnn4gigv",362"subscription_item": "si_MoA9zVoSS4gH2G",363"tax_amounts": [],364"tax_rates": [],365"type": "subscription",366"unit_amount_excluding_tax": "0"367},368{369"id": "il_1MA0RCAyBDPbWrhaogm8Cw8j",370"object": "line_item",371"amount": 900,372"amount_excluding_tax": 900,373"currency": "usd",374"description": "Gitpod Usage (Tier 1 at $9.00 / month)",375"discount_amounts": [],376"discountable": true,377"discounts": [],378"livemode": false,379"metadata": {},380"period": {381"end": 1669852800,382"start": 1668552093383},384"plan": {385"id": "price_1LmYDQAyBDPbWrhaiebWlzVX",386"object": "plan",387"active": true,388"aggregate_usage": "last_during_period",389"amount": null,390"amount_decimal": null,391"billing_scheme": "tiered",392"created": 1664263708,393"currency": "usd",394"interval": "month",395"interval_count": 1,396"livemode": false,397"metadata": {},398"nickname": "Individual USD",399"product": "prod_MIUT2nUscrEWBA",400"tiers_mode": "graduated",401"transform_usage": null,402"trial_period_days": null,403"usage_type": "metered"404},405"price": {406"id": "price_1LmYDQAyBDPbWrhaiebWlzVX",407"object": "price",408"active": true,409"billing_scheme": "tiered",410"created": 1664263708,411"currency": "usd",412"custom_unit_amount": null,413"livemode": false,414"lookup_key": null,415"metadata": {},416"nickname": "Individual USD",417"product": "prod_MIUT2nUscrEWBA",418"recurring": {419"aggregate_usage": "last_during_period",420"interval": "month",421"interval_count": 1,422"trial_period_days": null,423"usage_type": "metered"424},425"tax_behavior": "inclusive",426"tiers_mode": "graduated",427"transform_quantity": null,428"type": "recurring",429"unit_amount": null,430"unit_amount_decimal": null431},432"proration": false,433"proration_details": {434"credited_items": null435},436"quantity": 0,437"subscription": "sub_1M4XovAyBDPbWrhaCnn4gigv",438"subscription_item": "si_MoA9zVoSS4gH2G",439"tax_amounts": [],440"tax_rates": [],441"type": "subscription",442"unit_amount_excluding_tax": null443},444{445"id": "il_1MA0RDAyBDPbWrhaIF5LaBhx",446"object": "line_item",447"amount": 112,448"amount_excluding_tax": 112,449"currency": "usd",450"description": "31 credit × Gitpod Usage (Tier 2 at $0.036 / month)",451"discount_amounts": [],452"discountable": true,453"discounts": [],454"livemode": false,455"metadata": {},456"period": {457"end": 1669852800,458"start": 1668552093459},460"plan": {461"id": "price_1LmYDQAyBDPbWrhaiebWlzVX",462"object": "plan",463"active": true,464"aggregate_usage": "last_during_period",465"amount": null,466"amount_decimal": null,467"billing_scheme": "tiered",468"created": 1664263708,469"currency": "usd",470"interval": "month",471"interval_count": 1,472"livemode": false,473"metadata": {},474"nickname": "Individual USD",475"product": "prod_MIUT2nUscrEWBA",476"tiers_mode": "graduated",477"transform_usage": null,478"trial_period_days": null,479"usage_type": "metered"480},481"price": {482"id": "price_1LmYDQAyBDPbWrhaiebWlzVX",483"object": "price",484"active": true,485"billing_scheme": "tiered",486"created": 1664263708,487"currency": "usd",488"custom_unit_amount": null,489"livemode": false,490"lookup_key": null,491"metadata": {},492"nickname": "Individual USD",493"product": "prod_MIUT2nUscrEWBA",494"recurring": {495"aggregate_usage": "last_during_period",496"interval": "month",497"interval_count": 1,498"trial_period_days": null,499"usage_type": "metered"500},501"tax_behavior": "inclusive",502"tiers_mode": "graduated",503"transform_quantity": null,504"type": "recurring",505"unit_amount": null,506"unit_amount_decimal": null507},508"proration": false,509"proration_details": {510"credited_items": null511},512"quantity": 31,513"subscription": "sub_1M4XovAyBDPbWrhaCnn4gigv",514"subscription_item": "si_MoA9zVoSS4gH2G",515"tax_amounts": [],516"tax_rates": [],517"type": "subscription",518"unit_amount_excluding_tax": "4"519},520{521"id": "il_1MA0REAyBDPbWrhaMRhZhiJ6",522"object": "line_item",523"amount": 0,524"amount_excluding_tax": 0,525"currency": "usd",526"description": "Gitpod Usage (Tier 2 at $0.00 / month)",527"discount_amounts": [],528"discountable": true,529"discounts": [],530"livemode": false,531"metadata": {},532"period": {533"end": 1669852800,534"start": 1668552093535},536"plan": {537"id": "price_1LmYDQAyBDPbWrhaiebWlzVX",538"object": "plan",539"active": true,540"aggregate_usage": "last_during_period",541"amount": null,542"amount_decimal": null,543"billing_scheme": "tiered",544"created": 1664263708,545"currency": "usd",546"interval": "month",547"interval_count": 1,548"livemode": false,549"metadata": {},550"nickname": "Individual USD",551"product": "prod_MIUT2nUscrEWBA",552"tiers_mode": "graduated",553"transform_usage": null,554"trial_period_days": null,555"usage_type": "metered"556},557"price": {558"id": "price_1LmYDQAyBDPbWrhaiebWlzVX",559"object": "price",560"active": true,561"billing_scheme": "tiered",562"created": 1664263708,563"currency": "usd",564"custom_unit_amount": null,565"livemode": false,566"lookup_key": null,567"metadata": {},568"nickname": "Individual USD",569"product": "prod_MIUT2nUscrEWBA",570"recurring": {571"aggregate_usage": "last_during_period",572"interval": "month",573"interval_count": 1,574"trial_period_days": null,575"usage_type": "metered"576},577"tax_behavior": "inclusive",578"tiers_mode": "graduated",579"transform_quantity": null,580"type": "recurring",581"unit_amount": null,582"unit_amount_decimal": null583},584"proration": false,585"proration_details": {586"credited_items": null587},588"quantity": 0,589"subscription": "sub_1M4XovAyBDPbWrhaCnn4gigv",590"subscription_item": "si_MoA9zVoSS4gH2G",591"tax_amounts": [],592"tax_rates": [],593"type": "subscription",594"unit_amount_excluding_tax": null595}596],597"has_more": false,598"total_count": 4,599"url": "/v1/invoices/in_1MA0RBAyBDPbWrhabNdJIuhl/lines"600},601"livemode": false,602"metadata": {},603"next_payment_attempt": null,604"number": "DF67D6F2-0037",605"on_behalf_of": null,606"paid": true,607"paid_out_of_band": false,608"payment_intent": "pi_3MA1YjAyBDPbWrha1Tb6pdTW",609"payment_settings": {610"default_mandate": null,611"payment_method_options": null,612"payment_method_types": [613"card",614"link"615]616},617"period_end": 1669852800,618"period_start": 1668552093,619"post_payment_credit_notes_amount": 0,620"pre_payment_credit_notes_amount": 0,621"quote": null,622"receipt_number": "2061-6831",623"rendering_options": null,624"starting_balance": 0,625"statement_descriptor": null,626"status": "paid",627"status_transitions": {628"finalized_at": 1669858049,629"marked_uncollectible_at": null,630"paid_at": 1669948594,631"voided_at": null632},633"subscription": "sub_1M4XovAyBDPbWrhaCnn4gigv",634"subtotal": 1012,635"subtotal_excluding_tax": 1012,636"tax": null,637"test_clock": null,638"total": 1012,639"total_discount_amounts": [],640"total_excluding_tax": 1012,641"total_tax_amounts": [],642"transfer_data": null,643"webhooks_delivered_at": 1669853737644}`645646647