Skip to content
This repository was archived by the owner on Sep 30, 2024. It is now read-only.

Commit 4774879

Browse files
authored
feat(plg): Add new Checkout flow that uses Stripe's createToken API (#63213)
1 parent 5ac4721 commit 4774879

17 files changed

+625
-553
lines changed

client/web/BUILD.bazel

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -245,16 +245,18 @@ ts_project(
245245
"src/cody/management/api/teamMembers.ts",
246246
"src/cody/management/api/teamSubscriptions.ts",
247247
"src/cody/management/api/types.ts",
248+
"src/cody/management/subscription/StripeAddressElement.tsx",
249+
"src/cody/management/subscription/StripeCardDetails.tsx",
250+
"src/cody/management/subscription/manage/BillingAddress.tsx",
248251
"src/cody/management/subscription/manage/CodySubscriptionManagePage.tsx",
249252
"src/cody/management/subscription/manage/InvoiceHistory.tsx",
250253
"src/cody/management/subscription/manage/LoadingIconButton.tsx",
254+
"src/cody/management/subscription/manage/NonEditableBillingAddress.tsx",
251255
"src/cody/management/subscription/manage/PaymentDetails.tsx",
252256
"src/cody/management/subscription/manage/SubscriptionDetails.tsx",
253257
"src/cody/management/subscription/manage/utils.ts",
254258
"src/cody/management/subscription/new/CodyProCheckoutForm.tsx",
255-
"src/cody/management/subscription/new/CodyProCheckoutFormContainer.tsx",
256259
"src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx",
257-
"src/cody/management/subscription/new/PayButton.tsx",
258260
"src/cody/onboarding/CodyOnboarding.tsx",
259261
"src/cody/onboarding/EditorStep.tsx",
260262
"src/cody/onboarding/PurposeStep.tsx",

client/web/src/cody/management/api/client.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,12 @@ export module Client {
2020
return { method: 'PATCH', urlSuffix: '/team/current/subscription', requestBody }
2121
}
2222

23+
export function previewUpdateCurrentSubscription(
24+
requestBody: types.PreviewUpdateSubscriptionRequest
25+
): Call<types.PreviewResult> {
26+
return { method: 'PATCH', urlSuffix: '/team/current/subscription/preview', requestBody }
27+
}
28+
2329
export function getCurrentSubscriptionInvoices(): Call<types.GetSubscriptionInvoicesResponse> {
2430
return { method: 'GET', urlSuffix: '/team/current/subscription/invoices' }
2531
}
@@ -30,6 +36,16 @@ export module Client {
3036
return { method: 'POST', urlSuffix: '/team/current/subscription/reactivate', requestBody }
3137
}
3238

39+
// Teams
40+
41+
export function createTeam(requestBody: types.CreateTeamRequest): Call<string> {
42+
return { method: 'POST', urlSuffix: '/team', requestBody }
43+
}
44+
45+
export function previewCreateTeam(requestBody: types.PreviewCreateTeamRequest): Call<types.PreviewResult> {
46+
return { method: 'POST', urlSuffix: '/team/preview', requestBody }
47+
}
48+
3349
// Stripe Checkout
3450

3551
export function createStripeCheckoutSession(

client/web/src/cody/management/api/react-query/callCodyProApi.ts

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import type { Call } from '../client'
22

3+
export class CodyProApiError extends Error {
4+
constructor(message: string, public status: number) {
5+
super(message)
6+
}
7+
}
8+
39
/**
410
* Builds the RequestInit object for the fetch API with the necessary headers and options
511
* to authenticate the request with the Sourcegraph backend.
@@ -29,7 +35,9 @@ const signOutAndRedirectToSignIn = async (): Promise<void> => {
2935
}
3036
}
3137

32-
export const callCodyProApi = async <Data>(call: Call<Data>): Promise<Data | undefined> => {
38+
// Important: This function has the side effect of logging the user out and redirecting them
39+
// to the sign-in page with the current page as the return URL if they are not authenticated.
40+
export const callCodyProApi = async (call: Call<unknown>): Promise<Response> => {
3341
const response = await fetch(
3442
`/.api/ssc/proxy${call.urlSuffix}`,
3543
buildRequestInit({
@@ -41,14 +49,14 @@ export const callCodyProApi = async <Data>(call: Call<Data>): Promise<Data | und
4149
if (!response.ok) {
4250
if (response.status === 401) {
4351
await signOutAndRedirectToSignIn()
44-
// user is redirected to another page, no need to throw an error
45-
return undefined
52+
// User is redirected to another page, so no need to throw an error.
53+
return response
4654
}
4755

4856
// Throw errors for unsuccessful HTTP calls so that `callCodyProApi` callers don't need to check whether the response is OK.
4957
// Motivation taken from here: https://tanstack.com/query/latest/docs/framework/react/guides/query-functions#usage-with-fetch-and-other-clients-that-do-not-throw-by-default
50-
throw new Error(`Request to Cody Pro API failed with status ${response.status}`)
58+
throw new CodyProApiError(`Request to Cody Pro API failed: ${await response.text()}`, response.status)
5159
}
5260

53-
return (await response.json()) as Data
61+
return response
5462
}

client/web/src/cody/management/api/react-query/subscriptions.ts

Lines changed: 33 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,13 @@ import {
77
} from '@tanstack/react-query'
88

99
import { Client } from '../client'
10-
import type { UpdateSubscriptionRequest, Subscription } from '../teamSubscriptions'
10+
import type {
11+
UpdateSubscriptionRequest,
12+
Subscription,
13+
CreateTeamRequest,
14+
PreviewResult,
15+
PreviewCreateTeamRequest,
16+
} from '../types'
1117

1218
import { callCodyProApi } from './callCodyProApi'
1319

@@ -22,7 +28,10 @@ const queryKeys = {
2228
export const useCurrentSubscription = (): UseQueryResult<Subscription | undefined> =>
2329
useQuery({
2430
queryKey: queryKeys.subscription(),
25-
queryFn: async () => callCodyProApi(Client.getCurrentSubscription()),
31+
queryFn: async () => {
32+
const response = await callCodyProApi(Client.getCurrentSubscription())
33+
return response.ok ? response.json() : undefined
34+
},
2635
})
2736

2837
export const useUpdateCurrentSubscription = (): UseMutationResult<
@@ -32,7 +41,10 @@ export const useUpdateCurrentSubscription = (): UseMutationResult<
3241
> => {
3342
const queryClient = useQueryClient()
3443
return useMutation({
35-
mutationFn: async requestBody => callCodyProApi(Client.updateCurrentSubscription(requestBody)),
44+
mutationFn: async requestBody => {
45+
const response = await callCodyProApi(Client.updateCurrentSubscription(requestBody))
46+
return (await response.json()) as Subscription
47+
},
3648
onSuccess: data => {
3749
// We get updated subscription data in response - no need to refetch subscription.
3850
// All the `queryKeys.subscription()` subscribers (`useCurrentSubscription` callers) will get the updated value automatically.
@@ -45,3 +57,21 @@ export const useUpdateCurrentSubscription = (): UseMutationResult<
4557
},
4658
})
4759
}
60+
61+
export const useCreateTeam = (): UseMutationResult<void, Error, CreateTeamRequest> => {
62+
const queryClient = useQueryClient()
63+
return useMutation({
64+
mutationFn: async requestBody => {
65+
await callCodyProApi(Client.createTeam(requestBody))
66+
},
67+
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.all }),
68+
})
69+
}
70+
71+
export const usePreviewCreateTeam = (): UseMutationResult<PreviewResult | undefined, Error, PreviewCreateTeamRequest> =>
72+
useMutation({
73+
mutationFn: async requestBody => {
74+
const response = await callCodyProApi(Client.previewCreateTeam(requestBody))
75+
return (await response.json()) as PreviewResult
76+
},
77+
})

client/web/src/cody/management/api/teamSubscriptions.ts

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { TeamRole } from './teamMembers'
1+
import type { TeamRole } from './teamMembers'
22

33
// BillingInterval is the subscription's billing cycle. 'daily' is only
44
// available in the dev environment.
@@ -112,7 +112,29 @@ export interface UpdateSubscriptionRequest {
112112
subscriptionUpdate?: SubscriptionUpdateOptions
113113
}
114114

115+
export interface PreviewUpdateSubscriptionRequest {
116+
newSeatCount?: number
117+
newBillingInterval?: BillingInterval
118+
newCancelAtPeriodEnd?: boolean
119+
}
120+
115121
export interface GetSubscriptionInvoicesResponse {
116122
invoices: Invoice[]
117123
continuationToken?: string
118124
}
125+
126+
export interface CreateTeamRequest {
127+
name: string
128+
slug: string
129+
seats: number
130+
address: Address
131+
billingInterval: BillingInterval
132+
couponCode?: string
133+
creditCardToken: string
134+
}
135+
136+
export interface PreviewCreateTeamRequest {
137+
seats: number
138+
billingInterval: BillingInterval
139+
couponCode?: string
140+
}

client/web/src/cody/management/api/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Export all of the API types, so consumers we can organize the type definitions
1+
// Export all API types, so consumers we can organize the type definitions
22
// into smaller files, without consumers needing to care about that organization.
33
export * from './teamInvites'
44
export * from './teamMembers'
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import React, { useMemo } from 'react'
2+
3+
import { AddressElement } from '@stripe/react-stripe-js'
4+
import type { StripeAddressElementOptions } from '@stripe/stripe-js'
5+
6+
import type { Subscription } from '../api/types'
7+
8+
interface StripeAddressElementProps {
9+
subscription?: Subscription
10+
onFocus?: () => void
11+
}
12+
13+
export const StripeAddressElement: React.FC<StripeAddressElementProps> = ({ subscription, onFocus }) => {
14+
const options = useMemo(
15+
(): StripeAddressElementOptions => ({
16+
mode: 'billing',
17+
display: { name: 'full' },
18+
...(subscription
19+
? {
20+
defaultValues: {
21+
name: subscription.name,
22+
address: {
23+
...subscription.address,
24+
postal_code: subscription.address.postalCode,
25+
},
26+
},
27+
}
28+
: {}),
29+
autocomplete: { mode: 'automatic' },
30+
}),
31+
[subscription]
32+
)
33+
34+
return <AddressElement options={options} onFocus={onFocus} />
35+
}
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
import React, { useCallback } from 'react'
2+
3+
import { CardNumberElement, CardExpiryElement, CardCvcElement } from '@stripe/react-stripe-js'
4+
import type { StripeCardElementOptions } from '@stripe/stripe-js'
5+
6+
import { useTheme, Theme } from '@sourcegraph/shared/src/theme'
7+
import { Label, Text, Grid } from '@sourcegraph/wildcard'
8+
9+
const useCardElementOptions = (): ((type: 'number' | 'expiry' | 'cvc') => StripeCardElementOptions) => {
10+
const { theme } = useTheme()
11+
12+
return useCallback(
13+
(type: 'number' | 'expiry' | 'cvc') => ({
14+
...(type === 'number' ? { disableLink: true } : {}),
15+
16+
classes: {
17+
base: 'form-control py-2',
18+
focus: 'focus-visible',
19+
invalid: 'is-invalid',
20+
},
21+
22+
style: {
23+
base: {
24+
color: theme === Theme.Light ? '#262b38' : '#dbe2f0',
25+
},
26+
},
27+
}),
28+
[theme]
29+
)
30+
}
31+
32+
interface StripeCardDetailsProps {
33+
onFocus?: () => void
34+
className?: string
35+
}
36+
37+
export const StripeCardDetails: React.FC<StripeCardDetailsProps> = ({ onFocus, className }) => {
38+
const getOptions = useCardElementOptions()
39+
40+
return (
41+
<div className={className}>
42+
<div>
43+
<Label className="d-block">
44+
<Text className="mb-2">Card number</Text>
45+
<CardNumberElement options={getOptions('number')} onFocus={onFocus} />
46+
</Label>
47+
</div>
48+
49+
<Grid columnCount={2} className="mt-3 mb-0 pb-3">
50+
<Label className="d-block">
51+
<Text className="mb-2">Expiry date</Text>
52+
<CardExpiryElement options={getOptions('expiry')} onFocus={onFocus} />
53+
</Label>
54+
55+
<Label className="d-block">
56+
<Text className="mb-2">CVC</Text>
57+
<CardCvcElement options={getOptions('cvc')} onFocus={onFocus} />
58+
</Label>
59+
</Grid>
60+
</div>
61+
)
62+
}

0 commit comments

Comments
 (0)