Skip to content

Commit

Permalink
[server][dashboard] Allow new Stripe customers to select their prefer…
Browse files Browse the repository at this point in the history
…red billing currency
  • Loading branch information
jankeromnes committed Aug 30, 2022
1 parent bdf8ae0 commit 2274614
Show file tree
Hide file tree
Showing 7 changed files with 81 additions and 49 deletions.
39 changes: 31 additions & 8 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 Down Expand Up @@ -311,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 @@ -331,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 @@ -356,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 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
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 { BillableSession, BillableSessionRequest } 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
38 changes: 17 additions & 21 deletions components/server/ee/src/user/stripe-service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@
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";

@injectable()
Expand Down Expand Up @@ -50,14 +49,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 +61,13 @@ 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 },
});
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 +77,25 @@ export class StripeService {
teamId: team.id,
},
});
return customer;
}

async setPreferredCurrencyForCustomer(customer: Stripe.Customer, currency: string): Promise<void> {
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 +124,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 @@ -79,7 +79,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 @@ -2053,22 +2053,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);
} 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
11 changes: 4 additions & 7 deletions components/server/src/workspace/gitpod-server-impl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -171,7 +171,6 @@ import { Deferred } from "@gitpod/gitpod-protocol/lib/util/deferred";
import { InstallationAdminTelemetryDataProvider } from "../installation-admin/telemetry-data-provider";
import { LicenseEvaluator } from "@gitpod/licensor/lib";
import { Feature } from "@gitpod/licensor/lib/api";
import { Currency } from "@gitpod/gitpod-protocol/lib/plans";
import { getExperimentsClientForBackend } from "@gitpod/gitpod-protocol/lib/experiments/configcat-server";
import { BillableSession, BillableSessionRequest } from "@gitpod/gitpod-protocol/lib/usage";
import { WorkspaceClusterImagebuilderClientProvider } from "./workspace-cluster-imagebuilder-client-provider";
Expand Down Expand Up @@ -3215,12 +3214,10 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
async findStripeSubscriptionIdForTeam(ctx: TraceContext, teamId: string): Promise<string | undefined> {
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
}
async subscribeTeamToStripe(
ctx: TraceContext,
teamId: string,
setupIntentId: string,
currency: Currency,
): Promise<void> {
async createOrUpdateStripeCustomerForTeam(ctx: TraceContext, teamId: string, currency: string): Promise<void> {
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
}
async subscribeTeamToStripe(ctx: TraceContext, teamId: string, setupIntentId: string): Promise<void> {
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
}
async getStripePortalUrlForTeam(ctx: TraceContext, teamId: string): Promise<string> {
Expand Down

0 comments on commit 2274614

Please sign in to comment.