diff --git a/components/dashboard/src/teams/TeamUsageBasedBilling.tsx b/components/dashboard/src/teams/TeamUsageBasedBilling.tsx index 11fbcf84152539..9035b089562461 100644 --- a/components/dashboard/src/teams/TeamUsageBasedBilling.tsx +++ b/components/dashboard/src/teams/TeamUsageBasedBilling.tsx @@ -15,18 +15,18 @@ import { PaymentContext } from "../payment-context"; import { getGitpodService } from "../service/service"; import { ThemeContext } from "../theme-context"; -type PendingStripeCustomer = { pendingSince: number }; +type PendingStripeSubscription = { pendingSince: number }; export default function TeamUsageBasedBilling() { const { teams } = useContext(TeamsContext); const location = useLocation(); const team = getCurrentTeam(location, teams); const { showUsageBasedUI, currency } = useContext(PaymentContext); - const [stripeCustomerId, setStripeCustomerId] = useState(); + const [stripeSubscriptionId, setStripeSubscriptionId] = useState(); const [isLoading, setIsLoading] = useState(true); const [showBillingSetupModal, setShowBillingSetupModal] = useState(false); - const [pendingStripeCustomer, setPendingStripeCustomer] = useState(); - const [pollStripeCustomerTimeout, setPollStripeCustomerTimeout] = useState(); + const [pendingStripeSubscription, setPendingStripeSubscription] = useState(); + const [pollStripeSubscriptionTimeout, setPollStripeSubscriptionTimeout] = useState(); const [stripePortalUrl, setStripePortalUrl] = useState(); useEffect(() => { @@ -34,11 +34,11 @@ export default function TeamUsageBasedBilling() { return; } (async () => { - setStripeCustomerId(undefined); + setStripeSubscriptionId(undefined); setIsLoading(true); try { - const customerId = await getGitpodService().server.findStripeCustomerIdForTeam(team.id); - setStripeCustomerId(customerId); + const subscriptionId = await getGitpodService().server.findStripeSubscriptionIdForTeam(team.id); + setStripeSubscriptionId(subscriptionId); } catch (error) { console.error(error); } finally { @@ -48,14 +48,14 @@ export default function TeamUsageBasedBilling() { }, [team]); useEffect(() => { - if (!team || !stripeCustomerId) { + if (!team || !stripeSubscriptionId) { return; } (async () => { const portalUrl = await getGitpodService().server.getStripePortalUrlForTeam(team.id); setStripePortalUrl(portalUrl); })(); - }, [team, stripeCustomerId]); + }, [team, stripeSubscriptionId]); useEffect(() => { if (!team) { @@ -69,60 +69,63 @@ export default function TeamUsageBasedBilling() { const setupIntentId = params.get("setup_intent")!; window.history.replaceState({}, "", window.location.pathname); await getGitpodService().server.subscribeTeamToStripe(team.id, setupIntentId, currency); - const pendingCustomer = { pendingSince: Date.now() }; - setPendingStripeCustomer(pendingCustomer); - window.localStorage.setItem(`pendingStripeCustomerForTeam${team.id}`, JSON.stringify(pendingCustomer)); + const pendingSubscription = { pendingSince: Date.now() }; + setPendingStripeSubscription(pendingSubscription); + window.localStorage.setItem( + `pendingStripeSubscriptionForTeam${team.id}`, + JSON.stringify(pendingSubscription), + ); })(); }, [location.search, team]); useEffect(() => { - setPendingStripeCustomer(undefined); + setPendingStripeSubscription(undefined); if (!team) { return; } try { - const pendingStripeCustomer = window.localStorage.getItem(`pendingStripeCustomerForTeam${team.id}`); - if (!pendingStripeCustomer) { + const pendingStripeSubscription = window.localStorage.getItem(`pendingStripeSubscriptionForTeam${team.id}`); + if (!pendingStripeSubscription) { return; } - const pending = JSON.parse(pendingStripeCustomer); - setPendingStripeCustomer(pending); + const pending = JSON.parse(pendingStripeSubscription); + setPendingStripeSubscription(pending); } catch (error) { - console.error("Could not load pending stripe customer", team.id, error); + console.error("Could not load pending stripe subscription", team.id, error); } }, [team]); useEffect(() => { - if (!pendingStripeCustomer || !team) { + if (!pendingStripeSubscription || !team) { return; } - if (!!stripeCustomerId) { + if (!!stripeSubscriptionId) { // The upgrade was successful! - window.localStorage.removeItem(`pendingStripeCustomerForTeam${team.id}`); - clearTimeout(pollStripeCustomerTimeout!); - setPendingStripeCustomer(undefined); + window.localStorage.removeItem(`pendingStripeSubscriptionForTeam${team.id}`); + clearTimeout(pollStripeSubscriptionTimeout!); + setPendingStripeSubscription(undefined); return; } - if (pendingStripeCustomer.pendingSince + 1000 * 60 * 5 < Date.now()) { - // Pending Stripe customer expires after 5 minutes - window.localStorage.removeItem(`pendingStripeCustomerForTeam${team.id}`); - clearTimeout(pollStripeCustomerTimeout!); - setPendingStripeCustomer(undefined); + if (pendingStripeSubscription.pendingSince + 1000 * 60 * 5 < Date.now()) { + // Pending Stripe subscription expires after 5 minutes + window.localStorage.removeItem(`pendingStripeSubscriptionForTeam${team.id}`); + clearTimeout(pollStripeSubscriptionTimeout!); + setPendingStripeSubscription(undefined); return; } - if (!pollStripeCustomerTimeout) { - // Refresh Stripe customer in 5 seconds in order to poll for upgrade confirmation + if (!pollStripeSubscriptionTimeout) { + // Refresh Stripe subscription in 5 seconds in order to poll for upgrade confirmation const timeout = setTimeout(async () => { - const customerId = await getGitpodService().server.findStripeCustomerIdForTeam(team.id); - setStripeCustomerId(customerId); - setPollStripeCustomerTimeout(undefined); + const subscriptionId = await getGitpodService().server.findStripeSubscriptionIdForTeam(team.id); + setStripeSubscriptionId(subscriptionId); + setPollStripeSubscriptionTimeout(undefined); }, 5000); - setPollStripeCustomerTimeout(timeout); + setPollStripeSubscriptionTimeout(timeout); } return function cleanup() { - clearTimeout(pollStripeCustomerTimeout!); + clearTimeout(pollStripeSubscriptionTimeout!); }; - }, [pendingStripeCustomer, pollStripeCustomerTimeout, stripeCustomerId, team]); + }, [pendingStripeSubscription, pollStripeSubscriptionTimeout, stripeSubscriptionId, team]); if (!showUsageBasedUI) { return <>; @@ -135,12 +138,12 @@ export default function TeamUsageBasedBilling() {
Billing
- {(isLoading || pendingStripeCustomer) && ( + {(isLoading || pendingStripeSubscription) && ( <> )} - {!isLoading && !pendingStripeCustomer && !stripeCustomerId && ( + {!isLoading && !pendingStripeSubscription && !stripeSubscriptionId && ( <>
Inactive @@ -150,7 +153,7 @@ export default function TeamUsageBasedBilling() { )} - {!isLoading && !pendingStripeCustomer && !!stripeCustomerId && ( + {!isLoading && !pendingStripeSubscription && !!stripeSubscriptionId && ( <>
Active diff --git a/components/gitpod-protocol/src/gitpod-service.ts b/components/gitpod-protocol/src/gitpod-service.ts index 6fbda1972936a1..9d08bc5a7a0288 100644 --- a/components/gitpod-protocol/src/gitpod-service.ts +++ b/components/gitpod-protocol/src/gitpod-service.ts @@ -273,7 +273,7 @@ export interface GitpodServer extends JsonRpcServer, AdminServer, getStripePublishableKey(): Promise; getStripeSetupIntentClientSecret(): Promise; - findStripeCustomerIdForTeam(teamId: string): Promise; + findStripeSubscriptionIdForTeam(teamId: string): Promise; subscribeTeamToStripe(teamId: string, setupIntentId: string, currency: Currency): Promise; getStripePortalUrlForTeam(teamId: string): Promise; diff --git a/components/server/ee/src/user/stripe-service.ts b/components/server/ee/src/user/stripe-service.ts index ee7491c8b24757..027329ec65667c 100644 --- a/components/server/ee/src/user/stripe-service.ts +++ b/components/server/ee/src/user/stripe-service.ts @@ -115,6 +115,16 @@ export class StripeService { return session.url; } + async findUncancelledSubscriptionByCustomer(customerId: string): Promise { + const result = await this.getStripe().subscriptions.list({ + customer: customerId, + }); + if (result.data.length > 1) { + throw new Error(`Stripe customer '${customerId}') has more than one subscription!`); + } + return result.data[0]; + } + async createSubscriptionForCustomer(customerId: string, currency: Currency): Promise { const priceId = this.config?.stripeConfig?.usageProductPriceIds[currency]; if (!priceId) { diff --git a/components/server/ee/src/workspace/gitpod-server-impl.ts b/components/server/ee/src/workspace/gitpod-server-impl.ts index 1cffb6d42b5bc6..f0845b696ecfc1 100644 --- a/components/server/ee/src/workspace/gitpod-server-impl.ts +++ b/components/server/ee/src/workspace/gitpod-server-impl.ts @@ -1904,18 +1904,22 @@ export class GitpodServerEEImpl extends GitpodServerImpl { } } - async findStripeCustomerIdForTeam(ctx: TraceContext, teamId: string): Promise { - const user = this.checkAndBlockUser("findStripeCustomerIdForTeam"); + async findStripeSubscriptionIdForTeam(ctx: TraceContext, teamId: string): Promise { + const user = this.checkAndBlockUser("findStripeSubscriptionIdForTeam"); await this.ensureIsUsageBasedFeatureFlagEnabled(user); await this.guardTeamOperation(teamId, "update"); try { const customer = await this.stripeService.findCustomerByTeamId(teamId); - return customer?.id || undefined; + if (!customer?.id) { + return undefined; + } + const subscription = await this.stripeService.findUncancelledSubscriptionByCustomer(customer.id); + return subscription?.id; } catch (error) { - log.error(`Failed to get Stripe Customer ID for team '${teamId}'`, error); + log.error(`Failed to get Stripe Subscription ID for team '${teamId}'`, error); throw new ResponseError( ErrorCodes.INTERNAL_SERVER_ERROR, - `Failed to get Stripe Customer ID for team '${teamId}'`, + `Failed to get Stripe Subscription ID for team '${teamId}'`, ); } } @@ -1931,7 +1935,10 @@ export class GitpodServerEEImpl extends GitpodServerImpl { await this.guardTeamOperation(teamId, "update"); const team = await this.teamDB.findTeamById(teamId); try { - const customer = await this.stripeService.createCustomerForTeam(user, team!, setupIntentId); + let customer = await this.stripeService.findCustomerByTeamId(team!.id); + if (!customer) { + customer = await this.stripeService.createCustomerForTeam(user, team!, setupIntentId); + } await this.stripeService.createSubscriptionForCustomer(customer.id, currency); } catch (error) { log.error(`Failed to subscribe team '${teamId}' to Stripe`, error); diff --git a/components/server/src/auth/rate-limiter.ts b/components/server/src/auth/rate-limiter.ts index d5340c02346b3c..f1c06780c17c09 100644 --- a/components/server/src/auth/rate-limiter.ts +++ b/components/server/src/auth/rate-limiter.ts @@ -198,7 +198,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig { tsReassignSlot: { group: "default", points: 1 }, getStripePublishableKey: { group: "default", points: 1 }, getStripeSetupIntentClientSecret: { group: "default", points: 1 }, - findStripeCustomerIdForTeam: { group: "default", points: 1 }, + findStripeSubscriptionIdForTeam: { group: "default", points: 1 }, subscribeTeamToStripe: { group: "default", points: 1 }, getStripePortalUrlForTeam: { group: "default", points: 1 }, trackEvent: { group: "default", points: 1 }, diff --git a/components/server/src/workspace/gitpod-server-impl.ts b/components/server/src/workspace/gitpod-server-impl.ts index 860de92e1d10ce..af8caede67e280 100644 --- a/components/server/src/workspace/gitpod-server-impl.ts +++ b/components/server/src/workspace/gitpod-server-impl.ts @@ -3048,7 +3048,7 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable { async getStripeSetupIntentClientSecret(ctx: TraceContext): Promise { throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); } - async findStripeCustomerIdForTeam(ctx: TraceContext, teamId: string): Promise { + async findStripeSubscriptionIdForTeam(ctx: TraceContext, teamId: string): Promise { throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`); } async subscribeTeamToStripe(