Skip to content

Commit 91374b6

Browse files
committed
[server][dashboard] Allow new Stripe customers to select their billing currency
1 parent 803b52a commit 91374b6

File tree

7 files changed

+60
-35
lines changed

7 files changed

+60
-35
lines changed

components/dashboard/src/teams/TeamUsageBasedBilling.tsx

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,6 @@ export default function TeamUsageBasedBilling() {
2222
const { teams } = useContext(TeamsContext);
2323
const location = useLocation();
2424
const team = getCurrentTeam(location, teams);
25-
const { currency } = useContext(PaymentContext);
2625
const [teamBillingMode, setTeamBillingMode] = useState<BillingMode | undefined>(undefined);
2726
const [stripeSubscriptionId, setStripeSubscriptionId] = useState<string | undefined>();
2827
const [isLoading, setIsLoading] = useState<boolean>(true);
@@ -87,7 +86,7 @@ export default function TeamUsageBasedBilling() {
8786
JSON.stringify(pendingSubscription),
8887
);
8988
try {
90-
await getGitpodService().server.subscribeTeamToStripe(team.id, setupIntentId, currency);
89+
await getGitpodService().server.subscribeTeamToStripe(team.id, setupIntentId);
9190
} catch (error) {
9291
console.error("Could not subscribe team to Stripe", error);
9392
window.localStorage.removeItem(`pendingStripeSubscriptionForTeam${team.id}`);
@@ -214,7 +213,9 @@ export default function TeamUsageBasedBilling() {
214213
</div>
215214
)}
216215
</div>
217-
{showBillingSetupModal && <BillingSetupModal onClose={() => setShowBillingSetupModal(false)} />}
216+
{showBillingSetupModal && (
217+
<BillingSetupModal teamId={team?.id || ""} onClose={() => setShowBillingSetupModal(false)} />
218+
)}
218219
{showUpdateLimitModal && (
219220
<UpdateLimitModal
220221
currentValue={spendingLimit}
@@ -281,7 +282,7 @@ function UpdateLimitModal(props: {
281282
);
282283
}
283284

284-
function BillingSetupModal(props: { onClose: () => void }) {
285+
function BillingSetupModal(props: { teamId: string; onClose: () => void }) {
285286
const { isDark } = useContext(ThemeContext);
286287
const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | undefined>();
287288
const [stripeSetupIntentClientSecret, setStripeSetupIntentClientSecret] = useState<string | undefined>();
@@ -306,17 +307,18 @@ function BillingSetupModal(props: { onClose: () => void }) {
306307
clientSecret: stripeSetupIntentClientSecret,
307308
}}
308309
>
309-
<CreditCardInputForm />
310+
<CreditCardInputForm teamId={props.teamId} />
310311
</Elements>
311312
)}
312313
</div>
313314
</Modal>
314315
);
315316
}
316317

317-
function CreditCardInputForm() {
318+
function CreditCardInputForm(props: { teamId: string }) {
318319
const stripe = useStripe();
319320
const elements = useElements();
321+
const { currency } = useContext(PaymentContext);
320322
const [isLoading, setIsLoading] = useState<boolean>(false);
321323

322324
const handleSubmit = async (event: React.FormEvent) => {
@@ -326,6 +328,8 @@ function CreditCardInputForm() {
326328
}
327329
setIsLoading(true);
328330
try {
331+
// TODO(janx): Create Stripe customer for Team & currency
332+
await getGitpodService().server.createOrUpdateStripeCustomerForTeam(props.teamId, currency);
329333
const result = await stripe.confirmSetup({
330334
elements,
331335
confirmParams: {
@@ -350,6 +354,7 @@ function CreditCardInputForm() {
350354

351355
return (
352356
<form className="mt-4 flex-grow flex flex-col" onSubmit={handleSubmit}>
357+
<div>{/* FIXME(janx) */ currency}</div>
353358
<PaymentElement />
354359
<div className="mt-4 flex-grow flex flex-col justify-end items-end">
355360
<button className="my-0 flex items-center space-x-2" disabled={!stripe || isLoading}>

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";
@@ -290,7 +289,8 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
290289
getStripePublishableKey(): Promise<string>;
291290
getStripeSetupIntentClientSecret(): Promise<string>;
292291
findStripeSubscriptionIdForTeam(teamId: string): Promise<string | undefined>;
293-
subscribeTeamToStripe(teamId: string, setupIntentId: string, currency: Currency): Promise<void>;
292+
createOrUpdateStripeCustomerForTeam(teamId: string, currency: string): Promise<void>;
293+
subscribeTeamToStripe(teamId: string, setupIntentId: string): Promise<void>;
294294
getStripePortalUrlForTeam(teamId: string): Promise<string>;
295295
getSpendingLimitForTeam(teamId: string): Promise<number | undefined>;
296296
setSpendingLimitForTeam(teamId: string, spendingLimit: number): Promise<void>;

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

Lines changed: 16 additions & 9 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()
@@ -76,14 +75,10 @@ export class StripeService {
7675
return customer;
7776
}
7877

79-
async createCustomerForTeam(user: User, team: Team, setupIntentId: string): Promise<Stripe.Customer> {
78+
async createCustomerForTeam(user: User, team: Team): Promise<Stripe.Customer> {
8079
if (await this.findCustomerByTeamId(team.id)) {
8180
throw new Error(`A Stripe customer already exists for team '${team.id}'`);
8281
}
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-
}
8782
// Create the customer in Stripe
8883
const userName = User.getName(user);
8984
const customer = await this.getStripe().customers.create({
@@ -93,14 +88,25 @@ export class StripeService {
9388
teamId: team.id,
9489
},
9590
});
91+
return customer;
92+
}
93+
94+
async setBillingCurrencyForCustomer(customer: Stripe.Customer, currency: string): Promise<void> {
95+
// TODO(janx)
96+
}
97+
98+
async setDefaultPaymentMethodForCustomer(customer: Stripe.Customer, setupIntentId: string): Promise<void> {
99+
const setupIntent = await this.getStripe().setupIntents.retrieve(setupIntentId);
100+
if (typeof setupIntent.payment_method !== "string") {
101+
throw new Error("The provided Stripe SetupIntent does not have a valid payment method attached");
102+
}
96103
// Attach the provided payment method to the customer
97104
await this.getStripe().paymentMethods.attach(setupIntent.payment_method, {
98105
customer: customer.id,
99106
});
100107
await this.getStripe().customers.update(customer.id, {
101108
invoice_settings: { default_payment_method: setupIntent.payment_method },
102109
});
103-
return customer;
104110
}
105111

106112
async getPortalUrlForTeam(team: Team): Promise<string> {
@@ -129,15 +135,16 @@ export class StripeService {
129135
await this.getStripe().subscriptions.del(subscriptionId);
130136
}
131137

132-
async createSubscriptionForCustomer(customerId: string, currency: Currency): Promise<void> {
138+
async createSubscriptionForCustomer(customer: Stripe.Customer): Promise<void> {
139+
const currency = customer.currency || "USD"; // FIXME(janx)
133140
const priceId = this.config?.stripeConfig?.usageProductPriceIds[currency];
134141
if (!priceId) {
135142
throw new Error(`No Stripe Price ID configured for currency '${currency}'`);
136143
}
137144
const startOfNextMonth = new Date(new Date().toISOString().slice(0, 7) + "-01"); // First day of this month (YYYY-MM-01)
138145
startOfNextMonth.setMonth(startOfNextMonth.getMonth() + 1); // Add one month
139146
await this.getStripe().subscriptions.create({
140-
customer: customerId,
147+
customer: customer.id,
141148
items: [{ price: priceId }],
142149
billing_cycle_anchor: Math.round(startOfNextMonth.getTime() / 1000),
143150
});

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

Lines changed: 24 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";
@@ -2051,22 +2051,36 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
20512051
}
20522052
}
20532053

2054+
async createOrUpdateStripeCustomerForTeam(ctx: TraceContext, teamId: string, currency: string): Promise<void> {
2055+
const user = this.checkAndBlockUser("createOrUpdateStripeCustomerForTeam");
2056+
const team = await this.guardTeamOperation(teamId, "update");
2057+
await this.ensureStripeApiIsAllowed({ team });
2058+
try {
2059+
let customer = await this.stripeService.findCustomerByTeamId(team!.id);
2060+
if (!customer) {
2061+
customer = await this.stripeService.createCustomerForTeam(user, team!);
2062+
}
2063+
} catch (error) {
2064+
log.error(`Failed to update Stripe customer profile for team '${teamId}'`, error);
2065+
throw new ResponseError(
2066+
ErrorCodes.INTERNAL_SERVER_ERROR,
2067+
`Failed to update Stripe customer profile for team '${teamId}'`,
2068+
);
2069+
}
2070+
}
2071+
20542072
protected defaultSpendingLimit = 100;
2055-
async subscribeTeamToStripe(
2056-
ctx: TraceContext,
2057-
teamId: string,
2058-
setupIntentId: string,
2059-
currency: Currency,
2060-
): Promise<void> {
2061-
const user = this.checkAndBlockUser("subscribeUserToStripe");
2073+
async subscribeTeamToStripe(ctx: TraceContext, teamId: string, setupIntentId: string): Promise<void> {
2074+
this.checkAndBlockUser("subscribeUserToStripe");
20622075
const team = await this.guardTeamOperation(teamId, "update");
20632076
await this.ensureStripeApiIsAllowed({ team });
20642077
try {
20652078
let customer = await this.stripeService.findCustomerByTeamId(team!.id);
20662079
if (!customer) {
2067-
customer = await this.stripeService.createCustomerForTeam(user, team!, setupIntentId);
2080+
throw new Error(`No Stripe customer profile for team '${team.id}'`);
20682081
}
2069-
await this.stripeService.createSubscriptionForCustomer(customer.id, currency);
2082+
await this.stripeService.setDefaultPaymentMethodForCustomer(customer, setupIntentId);
2083+
await this.stripeService.createSubscriptionForCustomer(customer);
20702084

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

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -208,6 +208,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig {
208208
getStripePublishableKey: { group: "default", points: 1 },
209209
getStripeSetupIntentClientSecret: { group: "default", points: 1 },
210210
findStripeSubscriptionIdForTeam: { group: "default", points: 1 },
211+
createOrUpdateStripeCustomerForTeam: { group: "default", points: 1 },
211212
subscribeTeamToStripe: { group: "default", points: 1 },
212213
getStripePortalUrlForTeam: { group: "default", points: 1 },
213214
listBilledUsage: { group: "default", points: 1 },

components/server/src/config.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import * as yaml from "js-yaml";
1717
import { log } from "@gitpod/gitpod-protocol/lib/util/logging";
1818
import { filePathTelepresenceAware } from "@gitpod/gitpod-protocol/lib/env";
1919
import { WorkspaceClasses, WorkspaceClassesConfig } from "./workspace/workspace-classes";
20+
import { Currency } from "@gitpod/gitpod-protocol/lib/plans";
2021

2122
export const Config = Symbol("Config");
2223
export type Config = Omit<
@@ -27,7 +28,7 @@ export type Config = Omit<
2728
workspaceDefaults: WorkspaceDefaults;
2829
chargebeeProviderOptions?: ChargebeeProviderOptions;
2930
stripeSecrets?: { publishableKey: string; secretKey: string };
30-
stripeConfig?: { usageProductPriceIds: { EUR: string; USD: string } };
31+
stripeConfig?: { usageProductPriceIds: { [currency: string]: string } };
3132
builtinAuthProvidersConfigured: boolean;
3233
inactivityPeriodForRepos?: number;
3334
};

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";
@@ -3181,12 +3180,10 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
31813180
async findStripeSubscriptionIdForTeam(ctx: TraceContext, teamId: string): Promise<string | undefined> {
31823181
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
31833182
}
3184-
async subscribeTeamToStripe(
3185-
ctx: TraceContext,
3186-
teamId: string,
3187-
setupIntentId: string,
3188-
currency: Currency,
3189-
): Promise<void> {
3183+
async createOrUpdateStripeCustomerForTeam(ctx: TraceContext, teamId: string, currency: string): Promise<void> {
3184+
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
3185+
}
3186+
async subscribeTeamToStripe(ctx: TraceContext, teamId: string, setupIntentId: string): Promise<void> {
31903187
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
31913188
}
31923189
async getStripePortalUrlForTeam(ctx: TraceContext, teamId: string): Promise<string> {

0 commit comments

Comments
 (0)