Skip to content

Commit 6d11d50

Browse files
committed
[server][dashboard] Implement Stripe portal to allow usage-based customers to manage their subscription
1 parent 561b66a commit 6d11d50

File tree

6 files changed

+53
-10
lines changed

6 files changed

+53
-10
lines changed

components/dashboard/src/teams/TeamUsageBasedBilling.tsx

Lines changed: 20 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export default function TeamUsageBasedBilling() {
2727
const [showBillingSetupModal, setShowBillingSetupModal] = useState<boolean>(false);
2828
const [pendingStripeCustomer, setPendingStripeCustomer] = useState<PendingStripeCustomer | undefined>();
2929
const [pollStripeCustomerTimeout, setPollStripeCustomerTimeout] = useState<NodeJS.Timeout | undefined>();
30+
const [stripePortalUrl, setStripePortalUrl] = useState<string | undefined>();
3031

3132
useEffect(() => {
3233
if (!team) {
@@ -38,6 +39,10 @@ export default function TeamUsageBasedBilling() {
3839
try {
3940
const customerId = await getGitpodService().server.findStripeCustomerIdForTeam(team.id);
4041
setStripeCustomerId(customerId);
42+
if (customerId) {
43+
const portalUrl = await getGitpodService().server.getStripePortalUrlForTeam(team.id);
44+
setStripePortalUrl(portalUrl);
45+
}
4146
} catch (error) {
4247
console.error(error);
4348
} finally {
@@ -144,9 +149,11 @@ export default function TeamUsageBasedBilling() {
144149
<div className="text-xl font-semibold flex-grow text-gray-600 dark:text-gray-400">
145150
Active
146151
</div>
147-
{/* <button className="self-end secondary" disabled={true}>
148-
Manage →
149-
</button> */}
152+
<a href={stripePortalUrl}>
153+
<button className="self-end secondary" disabled={!stripePortalUrl}>
154+
Manage →
155+
</button>
156+
</a>
150157
</>
151158
)}
152159
</div>
@@ -156,6 +163,12 @@ export default function TeamUsageBasedBilling() {
156163
);
157164
}
158165

166+
function getStripeAppearance(isDark?: boolean): Appearance {
167+
return {
168+
theme: isDark ? "night" : "stripe",
169+
};
170+
}
171+
159172
function BillingSetupModal(props: { onClose: () => void }) {
160173
const { isDark } = useContext(ThemeContext);
161174
const [stripePromise, setStripePromise] = useState<Promise<Stripe | null> | undefined>();
@@ -169,20 +182,17 @@ function BillingSetupModal(props: { onClose: () => void }) {
169182
]).then((setters) => setters.forEach((s) => s()));
170183
}, []);
171184

172-
const getStripeAppearance = (): Appearance => {
173-
return {
174-
theme: isDark ? "night" : "stripe",
175-
};
176-
};
177-
178185
return (
179186
<Modal visible={true} onClose={props.onClose}>
180187
<h3 className="flex">Upgrade Billing</h3>
181188
<div className="border-t border-gray-200 dark:border-gray-800 mt-4 pt-2 h-96 -mx-6 px-6 flex flex-col">
182189
{!!stripePromise && !!stripeSetupIntentClientSecret && (
183190
<Elements
184191
stripe={stripePromise}
185-
options={{ appearance: getStripeAppearance(), clientSecret: stripeSetupIntentClientSecret }}
192+
options={{
193+
appearance: getStripeAppearance(isDark),
194+
clientSecret: stripeSetupIntentClientSecret,
195+
}}
186196
>
187197
<CreditCardInputForm />
188198
</Elements>

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,7 @@ export interface GitpodServer extends JsonRpcServer<GitpodClient>, AdminServer,
274274
getStripeSetupIntentClientSecret(): Promise<string>;
275275
findStripeCustomerIdForTeam(teamId: string): Promise<string | undefined>;
276276
subscribeTeamToStripe(teamId: string, setupIntentId: string): Promise<void>;
277+
getStripePortalUrlForTeam(teamId: string): Promise<string>;
277278

278279
/**
279280
* Analytics

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,4 +101,16 @@ export class StripeService {
101101
});
102102
return customer;
103103
}
104+
105+
async getPortalUrlForTeam(teamId: string): Promise<string> {
106+
const customer = await this.findCustomerByTeamId(teamId);
107+
if (!customer) {
108+
throw new Error(`No Stripe Customer ID found for team '${teamId}'`);
109+
}
110+
const session = await this.getStripe().billingPortal.sessions.create({
111+
customer: customer.id,
112+
// return_url: "?",
113+
});
114+
return session.url;
115+
}
104116
}

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

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1906,6 +1906,22 @@ export class GitpodServerEEImpl extends GitpodServerImpl {
19061906
}
19071907
}
19081908

1909+
async getStripePortalUrlForTeam(ctx: TraceContext, teamId: string): Promise<string> {
1910+
const user = this.checkAndBlockUser("getStripePortalUrlForTeam");
1911+
await this.ensureIsUsageBasedFeatureFlagEnabled(user);
1912+
await this.guardTeamOperation(teamId, "update");
1913+
try {
1914+
const url = await this.stripeService.getPortalUrlForTeam(teamId);
1915+
return url;
1916+
} catch (error) {
1917+
log.error(`Failed to get Stripe portal URL for team '${teamId}'`, error);
1918+
throw new ResponseError(
1919+
ErrorCodes.INTERNAL_SERVER_ERROR,
1920+
`Failed to get Stripe portal URL for team '${teamId}'`,
1921+
);
1922+
}
1923+
}
1924+
19091925
// (SaaS) – admin
19101926
async adminGetAccountStatement(ctx: TraceContext, userId: string): Promise<AccountStatement> {
19111927
traceAPIParams(ctx, { userId });

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

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,7 @@ function getConfig(config: RateLimiterConfig): RateLimiterConfig {
200200
getStripeSetupIntentClientSecret: { group: "default", points: 1 },
201201
findStripeCustomerIdForTeam: { group: "default", points: 1 },
202202
subscribeTeamToStripe: { group: "default", points: 1 },
203+
getStripePortalUrlForTeam: { group: "default", points: 1 },
203204
trackEvent: { group: "default", points: 1 },
204205
trackLocation: { group: "default", points: 1 },
205206
identifyUser: { group: "default", points: 1 },

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

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3045,6 +3045,9 @@ export class GitpodServerImpl implements GitpodServerWithTracing, Disposable {
30453045
async subscribeTeamToStripe(ctx: TraceContext, teamId: string, setupIntentId: string): Promise<void> {
30463046
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
30473047
}
3048+
async getStripePortalUrlForTeam(ctx: TraceContext, teamId: string): Promise<string> {
3049+
throw new ResponseError(ErrorCodes.SAAS_FEATURE, `Not implemented in this version`);
3050+
}
30483051
//
30493052
//#endregion
30503053
}

0 commit comments

Comments
 (0)