Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[server][dashboard] Allow new Stripe customers to select their billing currency #12376

Merged
merged 5 commits into from
Sep 1, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions components/dashboard/src/components/ContextMenu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ function ContextMenu(props: ContextMenuProps) {
setExpanded(!expanded);
};

const handler = (evt: KeyboardEvent) => {
const keydownHandler = (evt: KeyboardEvent) => {
if (evt.key === "Escape") {
setExpanded(false);
}
Expand All @@ -55,11 +55,11 @@ function ContextMenu(props: ContextMenuProps) {
};

useEffect(() => {
window.addEventListener("keydown", handler);
window.addEventListener("keydown", keydownHandler);
window.addEventListener("click", clickHandler);
// Remove event listeners on cleanup
return () => {
window.removeEventListener("keydown", handler);
window.removeEventListener("keydown", keydownHandler);
window.removeEventListener("click", clickHandler);
};
}, []); // Empty array ensures that effect is only run on mount and unmount
Expand Down
1 change: 0 additions & 1 deletion components/dashboard/src/components/Modal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ export default function Modal(props: {
"relative bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-800 rounded-xl p-6 max-w-lg mx-auto text-left " +
(props.className || "")
}
onClick={(e) => e.stopPropagation()}
>
{props.closeable !== false && (
<div
Expand Down
46 changes: 37 additions & 9 deletions components/dashboard/src/teams/TeamUsageBasedBilling.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,23 @@

import React, { useContext, useEffect, useState } from "react";
import { useLocation } from "react-router";
import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode";
import { Appearance, loadStripe, Stripe } from "@stripe/stripe-js";
import { Elements, PaymentElement, useElements, useStripe } from "@stripe/react-stripe-js";
import { getCurrentTeam, TeamsContext } from "./teams-context";
import DropDown from "../components/DropDown";
import Modal from "../components/Modal";
import { ReactComponent as Spinner } from "../icons/Spinner.svg";
import { PaymentContext } from "../payment-context";
import { getGitpodService } from "../service/service";
import { ThemeContext } from "../theme-context";
import { BillingMode } from "@gitpod/gitpod-protocol/lib/billing-mode";

type PendingStripeSubscription = { pendingSince: number };

export default function TeamUsageBasedBilling() {
const { teams } = useContext(TeamsContext);
const location = useLocation();
const team = getCurrentTeam(location, teams);
const { currency } = useContext(PaymentContext);
const [teamBillingMode, setTeamBillingMode] = useState<BillingMode | undefined>(undefined);
const [stripeSubscriptionId, setStripeSubscriptionId] = useState<string | undefined>();
const [isLoading, setIsLoading] = useState<boolean>(true);
Expand Down Expand Up @@ -87,7 +87,7 @@ export default function TeamUsageBasedBilling() {
JSON.stringify(pendingSubscription),
);
try {
await getGitpodService().server.subscribeTeamToStripe(team.id, setupIntentId, currency);
await getGitpodService().server.subscribeTeamToStripe(team.id, setupIntentId);
} catch (error) {
console.error("Could not subscribe team to Stripe", error);
window.localStorage.removeItem(`pendingStripeSubscriptionForTeam${team.id}`);
Expand Down Expand Up @@ -214,7 +214,9 @@ export default function TeamUsageBasedBilling() {
</div>
)}
</div>
{showBillingSetupModal && <BillingSetupModal onClose={() => setShowBillingSetupModal(false)} />}
{showBillingSetupModal && (
<BillingSetupModal teamId={team?.id || ""} onClose={() => setShowBillingSetupModal(false)} />
)}
{showUpdateLimitModal && (
<UpdateLimitModal
currentValue={spendingLimit}
Expand Down Expand Up @@ -281,7 +283,7 @@ function UpdateLimitModal(props: {
);
}

function BillingSetupModal(props: { onClose: () => void }) {
function BillingSetupModal(props: { teamId: string; onClose: () => void }) {
const { isDark } = useContext(ThemeContext);
const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | undefined>();
const [stripeSetupIntentClientSecret, setStripeSetupIntentClientSecret] = useState<string | undefined>();
Expand All @@ -297,7 +299,12 @@ function BillingSetupModal(props: { onClose: () => void }) {
return (
<Modal visible={true} onClose={props.onClose}>
<h3 className="flex">Upgrade Billing</h3>
<div className="border-t border-gray-200 dark:border-gray-800 mt-4 pt-2 h-96 -mx-6 px-6 flex flex-col">
<div className="border-t border-gray-200 dark:border-gray-800 mt-4 pt-2 -mx-6 px-6 flex flex-col">
{(!stripePromise || !stripeSetupIntentClientSecret) && (
<div className="h-80 flex items-center justify-center">
<Spinner className="h-5 w-5 animate-spin" />
</div>
)}
{!!stripePromise && !!stripeSetupIntentClientSecret && (
<Elements
stripe={stripePromise}
Expand All @@ -306,17 +313,18 @@ function BillingSetupModal(props: { onClose: () => void }) {
clientSecret: stripeSetupIntentClientSecret,
}}
>
<CreditCardInputForm />
<CreditCardInputForm teamId={props.teamId} />
</Elements>
)}
</div>
</Modal>
);
}

function CreditCardInputForm() {
function CreditCardInputForm(props: { teamId: string }) {
const stripe = useStripe();
const elements = useElements();
const { currency, setCurrency } = useContext(PaymentContext);
const [isLoading, setIsLoading] = useState<boolean>(false);

const handleSubmit = async (event: React.FormEvent) => {
Expand All @@ -326,6 +334,8 @@ function CreditCardInputForm() {
}
setIsLoading(true);
try {
// Create Stripe customer for team & currency (or update currency)
await getGitpodService().server.createOrUpdateStripeCustomerForTeam(props.teamId, currency);
const result = await stripe.confirmSetup({
elements,
confirmParams: {
Expand All @@ -351,7 +361,25 @@ function CreditCardInputForm() {
return (
<form className="mt-4 flex-grow flex flex-col" onSubmit={handleSubmit}>
<PaymentElement />
<div className="mt-4 flex-grow flex flex-col justify-end items-end">
<div className="mt-4 flex-grow flex justify-end items-end">
<div className="flex-grow flex space-x-1">
<span>Currency:</span>
<DropDown
customClasses="w-32"
renderAsLink={true}
activeEntry={currency}
entries={[
{
title: "EUR",
onClick: () => setCurrency("EUR"),
},
{
title: "USD",
onClick: () => setCurrency("USD"),
},
]}
/>
</div>
<button className="my-0 flex items-center space-x-2" disabled={!stripe || isLoading}>
<span>Add Payment Method</span>
{isLoading && <Spinner className="h-5 w-5 animate-spin filter brightness-150" />}
Expand Down
1 change: 1 addition & 0 deletions components/gitpod-db/src/accounting-db.spec.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ export class AccountingDBSpec {
db: AccountingDB;
queryRunner: QueryRunner;

@timeout(10000)
async before() {
const connection = await this.typeORM.getConnection();
const manager = connection.manager;
Expand Down
1 change: 1 addition & 0 deletions components/gitpod-db/src/auth-provider-entry.spec.db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export class AuthProviderEntryDBSpec {
typeORM = testContainer.get<TypeORM>(TypeORM);
db = testContainer.get<AuthProviderEntryDB>(AuthProviderEntryDB);

@timeout(10000)
async before() {
await this.clear();
}
Expand Down
4 changes: 2 additions & 2 deletions components/gitpod-protocol/src/gitpod-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,6 @@ import {
import { RemotePageMessage, RemoteTrackMessage, RemoteIdentifyMessage } from "./analytics";
import { IDEServer } from "./ide-protocol";
import { InstallationAdminSettings, TelemetryData } from "./installation-admin-protocol";
import { Currency } from "./plans";
import { ListBilledUsageResponse, ListBilledUsageRequest } from "./usage";
import { SupportedWorkspaceClass } from "./workspace-class";
import { BillingMode } from "./billing-mode";
Expand Down Expand Up @@ -292,7 +291,8 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
getStripePublishableKey(): Promise<string>;
getStripeSetupIntentClientSecret(): Promise<string>;
findStripeSubscriptionIdForTeam(teamId: string): Promise<string | undefined>;
subscribeTeamToStripe(teamId: string, setupIntentId: string, currency: Currency): Promise<void>;
createOrUpdateStripeCustomerForTeam(teamId: string, currency: string): Promise<void>;
subscribeTeamToStripe(teamId: string, setupIntentId: string): Promise<void>;
getStripePortalUrlForTeam(teamId: string): Promise<string>;
getSpendingLimitForTeam(teamId: string): Promise<number | undefined>;
setSpendingLimitForTeam(teamId: string, spendingLimit: number): Promise<void>;
Expand Down
57 changes: 36 additions & 21 deletions components/server/ee/src/user/stripe-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,9 +7,11 @@
import { inject, injectable } from "inversify";
import Stripe from "stripe";
import { Team, User } from "@gitpod/gitpod-protocol";
import { Currency } from "@gitpod/gitpod-protocol/lib/plans";
import { Config } from "../../../src/config";

const POLL_CREATED_CUSTOMER_INTERVAL_MS = 1000;
const POLL_CREATED_CUSTOMER_MAX_ATTEMPTS = 30;

@injectable()
export class StripeService {
@inject(Config) protected readonly config: Config;
Expand Down Expand Up @@ -50,14 +52,10 @@ export class StripeService {
return result.data[0];
}

async createCustomerForUser(user: User, setupIntentId: string): Promise<Stripe.Customer> {
async createCustomerForUser(user: User): Promise<Stripe.Customer> {
if (await this.findCustomerByUserId(user.id)) {
throw new Error(`A Stripe customer already exists for user '${user.id}'`);
}
const setupIntent = await this.getStripe().setupIntents.retrieve(setupIntentId);
if (typeof setupIntent.payment_method !== "string") {
throw new Error("The provided Stripe SetupIntent does not have a valid payment method attached");
}
// Create the customer in Stripe
const customer = await this.getStripe().customers.create({
email: User.getPrimaryEmail(user),
Expand All @@ -66,24 +64,21 @@ export class StripeService {
userId: user.id,
},
});
// Attach the provided payment method to the customer
await this.getStripe().paymentMethods.attach(setupIntent.payment_method, {
customer: customer.id,
});
await this.getStripe().customers.update(customer.id, {
invoice_settings: { default_payment_method: setupIntent.payment_method },
});
// Wait for the customer to show up in Stripe search results before proceeding
let attempts = 0;
while (!(await this.findCustomerByUserId(user.id))) {
await new Promise((resolve) => setTimeout(resolve, POLL_CREATED_CUSTOMER_INTERVAL_MS));
if (++attempts > POLL_CREATED_CUSTOMER_MAX_ATTEMPTS) {
throw new Error(`Could not confirm Stripe customer creation for user '${user.id}'`);
}
}
return customer;
}

async createCustomerForTeam(user: User, team: Team, setupIntentId: string): Promise<Stripe.Customer> {
async createCustomerForTeam(user: User, team: Team): Promise<Stripe.Customer> {
if (await this.findCustomerByTeamId(team.id)) {
throw new Error(`A Stripe customer already exists for team '${team.id}'`);
}
const setupIntent = await this.getStripe().setupIntents.retrieve(setupIntentId);
if (typeof setupIntent.payment_method !== "string") {
throw new Error("The provided Stripe SetupIntent does not have a valid payment method attached");
}
// Create the customer in Stripe
const userName = User.getName(user);
const customer = await this.getStripe().customers.create({
Expand All @@ -93,14 +88,33 @@ export class StripeService {
teamId: team.id,
},
});
// Wait for the customer to show up in Stripe search results before proceeding
let attempts = 0;
while (!(await this.findCustomerByTeamId(team.id))) {
await new Promise((resolve) => setTimeout(resolve, POLL_CREATED_CUSTOMER_INTERVAL_MS));
if (++attempts > POLL_CREATED_CUSTOMER_MAX_ATTEMPTS) {
throw new Error(`Could not confirm Stripe customer creation for team '${team.id}'`);
}
}
return customer;
}

async setPreferredCurrencyForCustomer(customer: Stripe.Customer, currency: string): Promise<void> {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do we ensure that currency is a valid value? Does Stripe reject an invalid type of currency?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

currency can be any string. Then, when we choose a Gitpod Product Price in Stripe, we see if one is available in our configuration for that specific string. If there is, great. If not, we pick the USD Price.

await this.getStripe().customers.update(customer.id, { metadata: { preferredCurrency: currency } });
}

async setDefaultPaymentMethodForCustomer(customer: Stripe.Customer, setupIntentId: string): Promise<void> {
const setupIntent = await this.getStripe().setupIntents.retrieve(setupIntentId);
if (typeof setupIntent.payment_method !== "string") {
throw new Error("The provided Stripe SetupIntent does not have a valid payment method attached");
}
// Attach the provided payment method to the customer
await this.getStripe().paymentMethods.attach(setupIntent.payment_method, {
customer: customer.id,
});
await this.getStripe().customers.update(customer.id, {
invoice_settings: { default_payment_method: setupIntent.payment_method },
});
return customer;
}

async getPortalUrlForTeam(team: Team): Promise<string> {
Expand Down Expand Up @@ -129,15 +143,16 @@ export class StripeService {
await this.getStripe().subscriptions.del(subscriptionId);
}

async createSubscriptionForCustomer(customerId: string, currency: Currency): Promise<void> {
async createSubscriptionForCustomer(customer: Stripe.Customer): Promise<void> {
const currency = customer.metadata.preferredCurrency || "USD";
const priceId = this.config?.stripeConfig?.usageProductPriceIds[currency];
if (!priceId) {
throw new Error(`No Stripe Price ID configured for currency '${currency}'`);
}
const startOfNextMonth = new Date(new Date().toISOString().slice(0, 7) + "-01"); // First day of this month (YYYY-MM-01)
startOfNextMonth.setMonth(startOfNextMonth.getMonth() + 1); // Add one month
await this.getStripe().subscriptions.create({
customer: customerId,
customer: customer.id,
items: [{ price: priceId }],
billing_cycle_anchor: Math.round(startOfNextMonth.getTime() / 1000),
});
Expand Down
35 changes: 25 additions & 10 deletions components/server/ee/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,7 @@ import {
TeamSubscriptionSlot,
TeamSubscriptionSlotResolved,
} from "@gitpod/gitpod-protocol/lib/team-subscription-protocol";
import { Currency, Plans } from "@gitpod/gitpod-protocol/lib/plans";
import { Plans } from "@gitpod/gitpod-protocol/lib/plans";
import * as pThrottle from "p-throttle";
import { formatDate } from "@gitpod/gitpod-protocol/lib/util/date-time";
import { FindUserByIdentityStrResult, UserService } from "../../../src/user/user-service";
Expand Down Expand Up @@ -2054,22 +2054,37 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
}
}

async createOrUpdateStripeCustomerForTeam(ctx: TraceContext, teamId: string, currency: string): Promise<void> {
const user = this.checkAndBlockUser("createOrUpdateStripeCustomerForTeam");
const team = await this.guardTeamOperation(teamId, "update");
await this.ensureStripeApiIsAllowed({ team });
try {
let customer = await this.stripeService.findCustomerByTeamId(team!.id);
if (!customer) {
customer = await this.stripeService.createCustomerForTeam(user, team!);
}
await this.stripeService.setPreferredCurrencyForCustomer(customer, currency);
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this allow me to change my currency after I've got a subscription in one currency? And if so, is that a problem?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would this allow me to change my currency after I've got a subscription in one currency?

Yes -- you can always cancel your Subscription, then buy a new Subscription in any available currency.

And if so, is that a problem?

No -- this is a feature. If you're paying in USD, but want to change to EUR, you can.

} catch (error) {
log.error(`Failed to update Stripe customer profile for team '${teamId}'`, error);
throw new ResponseError(
ErrorCodes.INTERNAL_SERVER_ERROR,
`Failed to update Stripe customer profile for team '${teamId}'`,
);
}
}

protected defaultSpendingLimit = 100;
async subscribeTeamToStripe(
ctx: TraceContext,
teamId: string,
setupIntentId: string,
currency: Currency,
): Promise<void> {
const user = this.checkAndBlockUser("subscribeUserToStripe");
async subscribeTeamToStripe(ctx: TraceContext, teamId: string, setupIntentId: string): Promise<void> {
this.checkAndBlockUser("subscribeUserToStripe");
const team = await this.guardTeamOperation(teamId, "update");
await this.ensureStripeApiIsAllowed({ team });
try {
let customer = await this.stripeService.findCustomerByTeamId(team!.id);
if (!customer) {
customer = await this.stripeService.createCustomerForTeam(user, team!, setupIntentId);
throw new Error(`No Stripe customer profile for team '${team.id}'`);
}
await this.stripeService.createSubscriptionForCustomer(customer.id, currency);
await this.stripeService.setDefaultPaymentMethodForCustomer(customer, setupIntentId);
await this.stripeService.createSubscriptionForCustomer(customer);

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

Expand Down
1 change: 1 addition & 0 deletions components/server/src/auth/rate-limiter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,6 +214,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig {
getStripePublishableKey: { group: "default", points: 1 },
getStripeSetupIntentClientSecret: { group: "default", points: 1 },
findStripeSubscriptionIdForTeam: { group: "default", points: 1 },
createOrUpdateStripeCustomerForTeam: { group: "default", points: 1 },
subscribeTeamToStripe: { group: "default", points: 1 },
getStripePortalUrlForTeam: { group: "default", points: 1 },
listBilledUsage: { group: "default", points: 1 },
Expand Down
2 changes: 1 addition & 1 deletion components/server/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ export type Config = Omit<
workspaceDefaults: WorkspaceDefaults;
chargebeeProviderOptions?: ChargebeeProviderOptions;
stripeSecrets?: { publishableKey: string; secretKey: string };
stripeConfig?: { usageProductPriceIds: { EUR: string; USD: string } };
stripeConfig?: { usageProductPriceIds: { [currency: string]: string } };
builtinAuthProvidersConfigured: boolean;
inactivityPeriodForRepos?: number;
};
Expand Down
Loading