Skip to content

Commit 2274614

Browse files
committed
[server][dashboard] Allow new Stripe customers to select their preferred billing currency
1 parent bdf8ae0 commit 2274614

File tree

7 files changed

+81
-49
lines changed

7 files changed

+81
-49
lines changed

components/dashboard/src/teams/TeamUsageBasedBilling.tsx

Lines changed: 31 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -6,23 +6,23 @@
66

77
import React, { useContext, useEffect, useState } from "react";
88
import { useLocation } from "react-router";
9+
import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode";
910
import { Appearance, loadStripe, Stripe } from "@stripe/stripe-js";
1011
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
1112
import { getCurrentTeam, TeamsContext } from "./teams-context";
13+
import DropDown from "../components/DropDown";
1214
import Modal from "../components/Modal";
1315
import { ReactComponent as Spinner } from "../icons/Spinner.svg";
1416
import { PaymentContext } from "../payment-context";
1517
import { getGitpodService } from "../service/service";
1618
import { ThemeContext } from "../theme-context";
17-
import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode";
1819

1920
type PendingStripeSubscription = { pendingSince: number };
2021

2122
export default function TeamUsageBasedBilling() {
2223
const { teams } = useContext(TeamsContext);
2324
const location = useLocation();
2425
const team = getCurrentTeam(location, teams);
25-
const { currency } = useContext(PaymentContext);
2626
const [teamBillingMode, setTeamBillingMode] = useState<BillingMode | undefined>(undefined);
2727
const [stripeSubscriptionId, setStripeSubscriptionId] = useState<string | undefined>();
2828
const [isLoading, setIsLoading] = useState<boolean>(true);
@@ -87,7 +87,7 @@ export default function TeamUsageBasedBilling() {
8787
JSON.stringify(pendingSubscription),
8888
);
8989
try {
90-
await getGitpodService().server.subscribeTeamToStripe(team.id, setupIntentId, currency);
90+
await getGitpodService().server.subscribeTeamToStripe(team.id, setupIntentId);
9191
} catch (error) {
9292
console.error("Could not subscribe team to Stripe", error);
9393
window.localStorage.removeItem(`pendingStripeSubscriptionForTeam${team.id}`);
@@ -214,7 +214,9 @@ export default function TeamUsageBasedBilling() {
214214
</div>
215215
)}
216216
</div>
217-
{showBillingSetupModal && <BillingSetupModal onClose={() => setShowBillingSetupModal(false)} />}
217+
{showBillingSetupModal && (
218+
<BillingSetupModal teamId={team?.id || ""} onClose={() => setShowBillingSetupModal(false)} />
219+
)}
218220
{showUpdateLimitModal && (
219221
<UpdateLimitModal
220222
currentValue={spendingLimit}
@@ -281,7 +283,7 @@ function UpdateLimitModal(props: {
281283
);
282284
}
283285

284-
function BillingSetupModal(props: { onClose: () => void }) {
286+
function BillingSetupModal(props: { teamId: string; onClose: () => void }) {
285287
const { isDark } = useContext(ThemeContext);
286288
const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | undefined>();
287289
const [stripeSetupIntentClientSecret, setStripeSetupIntentClientSecret] = useState<string | undefined>();
@@ -311,17 +313,18 @@ function BillingSetupModal(props: { onClose: () => void }) {
311313
clientSecret: stripeSetupIntentClientSecret,
312314
}}
313315
>
314-
<CreditCardInputForm />
316+
<CreditCardInputForm teamId={props.teamId} />
315317
</Elements>
316318
)}
317319
</div>
318320
</Modal>
319321
);
320322
}
321323

322-
function CreditCardInputForm() {
324+
function CreditCardInputForm(props: { teamId: string }) {
323325
const stripe = useStripe();
324326
const elements = useElements();
327+
const { currency, setCurrency } = useContext(PaymentContext);
325328
const [isLoading, setIsLoading] = useState<boolean>(false);
326329

327330
const handleSubmit = async (event: React.FormEvent) => {
@@ -331,6 +334,8 @@ function CreditCardInputForm() {
331334
}
332335
setIsLoading(true);
333336
try {
337+
// Create Stripe customer for team & currency (or update currency)
338+
await getGitpodService().server.createOrUpdateStripeCustomerForTeam(props.teamId, currency);
334339
const result = await stripe.confirmSetup({
335340
elements,
336341
confirmParams: {
@@ -356,7 +361,25 @@ function CreditCardInputForm() {
356361
return (
357362
<form className="mt-4 flex-grow flex flex-col" onSubmit={handleSubmit}>
358363
<PaymentElement />
359-
<div className="mt-4 flex-grow flex flex-col justify-end items-end">
364+
<div className="mt-4 flex-grow flex justify-end items-end">
365+
<div className="flex space-x-1">
366+
<span>Currency:</span>
367+
<DropDown
368+
customClasses="w-32"
369+
renderAsLink={true}
370+
activeEntry={currency}
371+
entries={[
372+
{
373+
title: "EUR",
374+
onClick: () => setCurrency("EUR"),
375+
},
376+
{
377+
title: "USD",
378+
onClick: () => setCurrency("USD"),
379+
},
380+
]}
381+
/>
382+
</div>
360383
<button className="my-0 flex items-center space-x-2" disabled={!stripe || isLoading}>
361384
<span>Add Payment Method</span>
362385
{isLoading && <Spinner className="h-5 w-5 animate-spin filter brightness-150" />}

components/gitpod-protocol/src/gitpod-service.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -60,7 +60,6 @@ import {
6060
import { RemotePageMessage, RemoteTrackMessage, RemoteIdentifyMessage } from "./analytics";
6161
import { IDEServer } from "./ide-protocol";
6262
import { InstallationAdminSettings, TelemetryData } from "./installation-admin-protocol";
63-
import { Currency } from "./plans";
6463
import { BillableSession, BillableSessionRequest } from "./usage";
6564
import { SupportedWorkspaceClass } from "./workspace-class";
6665
import { BillingMode } from "./billing-mode";
@@ -292,7 +291,8 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
292291
getStripePublishableKey(): Promise<string>;
293292
getStripeSetupIntentClientSecret(): Promise<string>;
294293
findStripeSubscriptionIdForTeam(teamId: string): Promise<string | undefined>;
295-
subscribeTeamToStripe(teamId: string, setupIntentId: string, currency: Currency): Promise<void>;
294+
createOrUpdateStripeCustomerForTeam(teamId: string, currency: string): Promise<void>;
295+
subscribeTeamToStripe(teamId: string, setupIntentId: string): Promise<void>;
296296
getStripePortalUrlForTeam(teamId: string): Promise<string>;
297297
getSpendingLimitForTeam(teamId: string): Promise<number | undefined>;
298298
setSpendingLimitForTeam(teamId: string, spendingLimit: number): Promise<void>;

components/server/ee/src/user/stripe-service.ts

Lines changed: 17 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,6 @@
77
import { inject, injectable } from "inversify";
88
import Stripe from "stripe";
99
import { Team, User } from "@gitpod/gitpod-protocol";
10-
import { Currency } from "@gitpod/gitpod-protocol/lib/plans";
1110
import { Config } from "../../../src/config";
1211

1312
@injectable()
@@ -50,14 +49,10 @@ export class StripeService {
5049
return result.data[0];
5150
}
5251

53-
async createCustomerForUser(user: User, setupIntentId: string): Promise<Stripe.Customer> {
52+
async createCustomerForUser(user: User): Promise<Stripe.Customer> {
5453
if (await this.findCustomerByUserId(user.id)) {
5554
throw new Error(`A Stripe customer already exists for user '${user.id}'`);
5655
}
57-
const setupIntent = await this.getStripe().setupIntents.retrieve(setupIntentId);
58-
if (typeof setupIntent.payment_method !== "string") {
59-
throw new Error("The provided Stripe SetupIntent does not have a valid payment method attached");
60-
}
6156
// Create the customer in Stripe
6257
const customer = await this.getStripe().customers.create({
6358
email: User.getPrimaryEmail(user),
@@ -66,24 +61,13 @@ export class StripeService {
6661
userId: user.id,
6762
},
6863
});
69-
// Attach the provided payment method to the customer
70-
await this.getStripe().paymentMethods.attach(setupIntent.payment_method, {
71-
customer: customer.id,
72-
});
73-
await this.getStripe().customers.update(customer.id, {
74-
invoice_settings: { default_payment_method: setupIntent.payment_method },
75-
});
7664
return customer;
7765
}
7866

79-
async createCustomerForTeam(user: User, team: Team, setupIntentId: string): Promise<Stripe.Customer> {
67+
async createCustomerForTeam(user: User, team: Team): Promise<Stripe.Customer> {
8068
if (await this.findCustomerByTeamId(team.id)) {
8169
throw new Error(`A Stripe customer already exists for team '${team.id}'`);
8270
}
83-
const setupIntent = await this.getStripe().setupIntents.retrieve(setupIntentId);
84-
if (typeof setupIntent.payment_method !== "string") {
85-
throw new Error("The provided Stripe SetupIntent does not have a valid payment method attached");
86-
}
8771
// Create the customer in Stripe
8872
const userName = User.getName(user);
8973
const customer = await this.getStripe().customers.create({
@@ -93,14 +77,25 @@ export class StripeService {
9377
teamId: team.id,
9478
},
9579
});
80+
return customer;
81+
}
82+
83+
async setPreferredCurrencyForCustomer(customer: Stripe.Customer, currency: string): Promise<void> {
84+
await this.getStripe().customers.update(customer.id, { metadata: { preferredCurrency: currency } });
85+
}
86+
87+
async setDefaultPaymentMethodForCustomer(customer: Stripe.Customer, setupIntentId: string): Promise<void> {
88+
const setupIntent = await this.getStripe().setupIntents.retrieve(setupIntentId);
89+
if (typeof setupIntent.payment_method !== "string") {
90+
throw new Error("The provided Stripe SetupIntent does not have a valid payment method attached");
91+
}
9692
// Attach the provided payment method to the customer
9793
await this.getStripe().paymentMethods.attach(setupIntent.payment_method, {
9894
customer: customer.id,
9995
});
10096
await this.getStripe().customers.update(customer.id, {
10197
invoice_settings: { default_payment_method: setupIntent.payment_method },
10298
});
103-
return customer;
10499
}
105100

106101
async getPortalUrlForTeam(team: Team): Promise<string> {
@@ -129,15 +124,16 @@ export class StripeService {
129124
await this.getStripe().subscriptions.del(subscriptionId);
130125
}
131126

132-
async createSubscriptionForCustomer(customerId: string, currency: Currency): Promise<void> {
127+
async createSubscriptionForCustomer(customer: Stripe.Customer): Promise<void> {
128+
const currency = customer.metadata.preferredCurrency || "USD";
133129
const priceId = this.config?.stripeConfig?.usageProductPriceIds[currency];
134130
if (!priceId) {
135131
throw new Error(`No Stripe Price ID configured for currency '${currency}'`);
136132
}
137133
const startOfNextMonth = new Date(new Date().toISOString().slice(0, 7) + "-01"); // First day of this month (YYYY-MM-01)
138134
startOfNextMonth.setMonth(startOfNextMonth.getMonth() + 1); // Add one month
139135
await this.getStripe().subscriptions.create({
140-
customer: customerId,
136+
customer: customer.id,
141137
items: [{ price: priceId }],
142138
billing_cycle_anchor: Math.round(startOfNextMonth.getTime() / 1000),
143139
});

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

Lines changed: 25 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ import {
7979
TeamSubscriptionSlot,
8080
TeamSubscriptionSlotResolved,
8181
} from "@gitpod/gitpod-protocol/lib/team-subscription-protocol";
82-
import { Currency, Plans } from "@gitpod/gitpod-protocol/lib/plans";
82+
import { Plans } from "@gitpod/gitpod-protocol/lib/plans";
8383
import * as pThrottle from "p-throttle";
8484
import { formatDate } from "@gitpod/gitpod-protocol/lib/util/date-time";
8585
import { FindUserByIdentityStrResult, UserService } from "../../../src/user/user-service";
@@ -2053,22 +2053,37 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
20532053
}
20542054
}
20552055

2056+
async createOrUpdateStripeCustomerForTeam(ctx: TraceContext, teamId: string, currency: string): Promise<void> {
2057+
const user = this.checkAndBlockUser("createOrUpdateStripeCustomerForTeam");
2058+
const team = await this.guardTeamOperation(teamId, "update");
2059+
await this.ensureStripeApiIsAllowed({ team });
2060+
try {
2061+
let customer = await this.stripeService.findCustomerByTeamId(team!.id);
2062+
if (!customer) {
2063+
customer = await this.stripeService.createCustomerForTeam(user, team!);
2064+
}
2065+
await this.stripeService.setPreferredCurrencyForCustomer(customer, currency);
2066+
} catch (error) {
2067+
log.error(`Failed to update Stripe customer profile for team '${teamId}'`, error);
2068+
throw new ResponseError(
2069+
ErrorCodes.INTERNAL_SERVER_ERROR,
2070+
`Failed to update Stripe customer profile for team '${teamId}'`,
2071+
);
2072+
}
2073+
}
2074+
20562075
protected defaultSpendingLimit = 100;
2057-
async subscribeTeamToStripe(
2058-
ctx: TraceContext,
2059-
teamId: string,
2060-
setupIntentId: string,
2061-
currency: Currency,
2062-
): Promise<void> {
2063-
const user = this.checkAndBlockUser("subscribeUserToStripe");
2076+
async subscribeTeamToStripe(ctx: TraceContext, teamId: string, setupIntentId: string): Promise<void> {
2077+
this.checkAndBlockUser("subscribeUserToStripe");
20642078
const team = await this.guardTeamOperation(teamId, "update");
20652079
await this.ensureStripeApiIsAllowed({ team });
20662080
try {
20672081
let customer = await this.stripeService.findCustomerByTeamId(team!.id);
20682082
if (!customer) {
2069-
customer = await this.stripeService.createCustomerForTeam(user, team!, setupIntentId);
2083+
throw new Error(`No Stripe customer profile for team '${team.id}'`);
20702084
}
2071-
await this.stripeService.createSubscriptionForCustomer(customer.id, currency);
2085+
await this.stripeService.setDefaultPaymentMethodForCustomer(customer, setupIntentId);
2086+
await this.stripeService.createSubscriptionForCustomer(customer);
20722087

20732088
const attributionId = AttributionId.render({ kind: "team", teamId });
20742089

components/server/src/auth/rate-limiter.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -214,6 +214,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig {
214214
getStripePublishableKey: { group: "default", points: 1 },
215215
getStripeSetupIntentClientSecret: { group: "default", points: 1 },
216216
findStripeSubscriptionIdForTeam: { group: "default", points: 1 },
217+
createOrUpdateStripeCustomerForTeam: { group: "default", points: 1 },
217218
subscribeTeamToStripe: { group: "default", points: 1 },
218219
getStripePortalUrlForTeam: { group: "default", points: 1 },
219220
listBilledUsage: { group: "default", points: 1 },

components/server/src/config.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ export type Config = Omit<
2727
workspaceDefaults: WorkspaceDefaults;
2828
chargebeeProviderOptions?: ChargebeeProviderOptions;
2929
stripeSecrets?: { publishableKey: string; secretKey: string };
30-
stripeConfig?: { usageProductPriceIds: { EUR: string; USD: string } };
30+
stripeConfig?: { usageProductPriceIds: { [currency: string]: string } };
3131
builtinAuthProvidersConfigured: boolean;
3232
inactivityPeriodForRepos?: number;
3333
};

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

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -171,7 +171,6 @@ import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred";
171171
import { InstallationAdminTelemetryDataProvider } from "../installation-admin/telemetry-data-provider";
172172
import { LicenseEvaluator } from "@gitpod/licensor/lib";
173173
import { Feature } from "@gitpod/licensor/lib/api";
174-
import { Currency } from "@gitpod/gitpod-protocol/lib/plans";
175174
import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server";
176175
import { BillableSession, BillableSessionRequest } from "@gitpod/gitpod-protocol/lib/usage";
177176
import { WorkspaceClusterImagebuilderClientProvider } from "./workspace-cluster-imagebuilder-client-provider";
@@ -3215,12 +3214,10 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
32153214
async findStripeSubscriptionIdForTeam(ctx: TraceContext, teamId: string): Promise<string | undefined> {
32163215
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
32173216
}
3218-
async subscribeTeamToStripe(
3219-
ctx: TraceContext,
3220-
teamId: string,
3221-
setupIntentId: string,
3222-
currency: Currency,
3223-
): Promise<void> {
3217+
async createOrUpdateStripeCustomerForTeam(ctx: TraceContext, teamId: string, currency: string): Promise<void> {
3218+
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
3219+
}
3220+
async subscribeTeamToStripe(ctx: TraceContext, teamId: string, setupIntentId: string): Promise<void> {
32243221
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
32253222
}
32263223
async getStripePortalUrlForTeam(ctx: TraceContext, teamId: string): Promise<string> {

0 commit comments

Comments
 (0)