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

Commit

Permalink
feat(plg): Add new Checkout flow that uses Stripe's createToken API (
Browse files Browse the repository at this point in the history
  • Loading branch information
vdavid authored Jun 12, 2024
1 parent 5ac4721 commit 4774879
Show file tree
Hide file tree
Showing 17 changed files with 625 additions and 553 deletions.
6 changes: 4 additions & 2 deletions client/web/BUILD.bazel
Original file line number Diff line number Diff line change
Expand Up @@ -245,16 +245,18 @@ ts_project(
"src/cody/management/api/teamMembers.ts",
"src/cody/management/api/teamSubscriptions.ts",
"src/cody/management/api/types.ts",
"src/cody/management/subscription/StripeAddressElement.tsx",
"src/cody/management/subscription/StripeCardDetails.tsx",
"src/cody/management/subscription/manage/BillingAddress.tsx",
"src/cody/management/subscription/manage/CodySubscriptionManagePage.tsx",
"src/cody/management/subscription/manage/InvoiceHistory.tsx",
"src/cody/management/subscription/manage/LoadingIconButton.tsx",
"src/cody/management/subscription/manage/NonEditableBillingAddress.tsx",
"src/cody/management/subscription/manage/PaymentDetails.tsx",
"src/cody/management/subscription/manage/SubscriptionDetails.tsx",
"src/cody/management/subscription/manage/utils.ts",
"src/cody/management/subscription/new/CodyProCheckoutForm.tsx",
"src/cody/management/subscription/new/CodyProCheckoutFormContainer.tsx",
"src/cody/management/subscription/new/NewCodyProSubscriptionPage.tsx",
"src/cody/management/subscription/new/PayButton.tsx",
"src/cody/onboarding/CodyOnboarding.tsx",
"src/cody/onboarding/EditorStep.tsx",
"src/cody/onboarding/PurposeStep.tsx",
Expand Down
16 changes: 16 additions & 0 deletions client/web/src/cody/management/api/client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,12 @@ export module Client {
return { method: 'PATCH', urlSuffix: '/team/current/subscription', requestBody }
}

export function previewUpdateCurrentSubscription(
requestBody: types.PreviewUpdateSubscriptionRequest
): Call<types.PreviewResult> {
return { method: 'PATCH', urlSuffix: '/team/current/subscription/preview', requestBody }
}

export function getCurrentSubscriptionInvoices(): Call<types.GetSubscriptionInvoicesResponse> {
return { method: 'GET', urlSuffix: '/team/current/subscription/invoices' }
}
Expand All @@ -30,6 +36,16 @@ export module Client {
return { method: 'POST', urlSuffix: '/team/current/subscription/reactivate', requestBody }
}

// Teams

export function createTeam(requestBody: types.CreateTeamRequest): Call<string> {
return { method: 'POST', urlSuffix: '/team', requestBody }
}

export function previewCreateTeam(requestBody: types.PreviewCreateTeamRequest): Call<types.PreviewResult> {
return { method: 'POST', urlSuffix: '/team/preview', requestBody }
}

// Stripe Checkout

export function createStripeCheckoutSession(
Expand Down
18 changes: 13 additions & 5 deletions client/web/src/cody/management/api/react-query/callCodyProApi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,11 @@
import type { Call } from '../client'

export class CodyProApiError extends Error {
constructor(message: string, public status: number) {
super(message)
}
}

/**
* Builds the RequestInit object for the fetch API with the necessary headers and options
* to authenticate the request with the Sourcegraph backend.
Expand Down Expand Up @@ -29,7 +35,9 @@ const signOutAndRedirectToSignIn = async (): Promise<void> => {
}
}

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

// Throw errors for unsuccessful HTTP calls so that `callCodyProApi` callers don't need to check whether the response is OK.
// 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
throw new Error(`Request to Cody Pro API failed with status ${response.status}`)
throw new CodyProApiError(`Request to Cody Pro API failed: ${await response.text()}`, response.status)
}

return (await response.json()) as Data
return response
}
36 changes: 33 additions & 3 deletions client/web/src/cody/management/api/react-query/subscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,13 @@ import {
} from '@tanstack/react-query'

import { Client } from '../client'
import type { UpdateSubscriptionRequest, Subscription } from '../teamSubscriptions'
import type {
UpdateSubscriptionRequest,
Subscription,
CreateTeamRequest,
PreviewResult,
PreviewCreateTeamRequest,
} from '../types'

import { callCodyProApi } from './callCodyProApi'

Expand All @@ -22,7 +28,10 @@ const queryKeys = {
export const useCurrentSubscription = (): UseQueryResult<Subscription | undefined> =>
useQuery({
queryKey: queryKeys.subscription(),
queryFn: async () => callCodyProApi(Client.getCurrentSubscription()),
queryFn: async () => {
const response = await callCodyProApi(Client.getCurrentSubscription())
return response.ok ? response.json() : undefined
},
})

export const useUpdateCurrentSubscription = (): UseMutationResult<
Expand All @@ -32,7 +41,10 @@ export const useUpdateCurrentSubscription = (): UseMutationResult<
> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async requestBody => callCodyProApi(Client.updateCurrentSubscription(requestBody)),
mutationFn: async requestBody => {
const response = await callCodyProApi(Client.updateCurrentSubscription(requestBody))
return (await response.json()) as Subscription
},
onSuccess: data => {
// We get updated subscription data in response - no need to refetch subscription.
// All the `queryKeys.subscription()` subscribers (`useCurrentSubscription` callers) will get the updated value automatically.
Expand All @@ -45,3 +57,21 @@ export const useUpdateCurrentSubscription = (): UseMutationResult<
},
})
}

export const useCreateTeam = (): UseMutationResult<void, Error, CreateTeamRequest> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async requestBody => {
await callCodyProApi(Client.createTeam(requestBody))
},
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.all }),
})
}

export const usePreviewCreateTeam = (): UseMutationResult<PreviewResult | undefined, Error, PreviewCreateTeamRequest> =>
useMutation({
mutationFn: async requestBody => {
const response = await callCodyProApi(Client.previewCreateTeam(requestBody))
return (await response.json()) as PreviewResult
},
})
24 changes: 23 additions & 1 deletion client/web/src/cody/management/api/teamSubscriptions.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { TeamRole } from './teamMembers'
import type { TeamRole } from './teamMembers'

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

export interface PreviewUpdateSubscriptionRequest {
newSeatCount?: number
newBillingInterval?: BillingInterval
newCancelAtPeriodEnd?: boolean
}

export interface GetSubscriptionInvoicesResponse {
invoices: Invoice[]
continuationToken?: string
}

export interface CreateTeamRequest {
name: string
slug: string
seats: number
address: Address
billingInterval: BillingInterval
couponCode?: string
creditCardToken: string
}

export interface PreviewCreateTeamRequest {
seats: number
billingInterval: BillingInterval
couponCode?: string
}
2 changes: 1 addition & 1 deletion client/web/src/cody/management/api/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// Export all of the API types, so consumers we can organize the type definitions
// Export all API types, so consumers we can organize the type definitions
// into smaller files, without consumers needing to care about that organization.
export * from './teamInvites'
export * from './teamMembers'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import React, { useMemo } from 'react'

import { AddressElement } from '@stripe/react-stripe-js'
import type { StripeAddressElementOptions } from '@stripe/stripe-js'

import type { Subscription } from '../api/types'

interface StripeAddressElementProps {
subscription?: Subscription
onFocus?: () => void
}

export const StripeAddressElement: React.FC<StripeAddressElementProps> = ({ subscription, onFocus }) => {
const options = useMemo(
(): StripeAddressElementOptions => ({
mode: 'billing',
display: { name: 'full' },
...(subscription
? {
defaultValues: {
name: subscription.name,
address: {
...subscription.address,
postal_code: subscription.address.postalCode,
},
},
}
: {}),
autocomplete: { mode: 'automatic' },
}),
[subscription]
)

return <AddressElement options={options} onFocus={onFocus} />
}
62 changes: 62 additions & 0 deletions client/web/src/cody/management/subscription/StripeCardDetails.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import React, { useCallback } from 'react'

import { CardNumberElement, CardExpiryElement, CardCvcElement } from '@stripe/react-stripe-js'
import type { StripeCardElementOptions } from '@stripe/stripe-js'

import { useTheme, Theme } from '@sourcegraph/shared/src/theme'
import { Label, Text, Grid } from '@sourcegraph/wildcard'

const useCardElementOptions = (): ((type: 'number' | 'expiry' | 'cvc') => StripeCardElementOptions) => {
const { theme } = useTheme()

return useCallback(
(type: 'number' | 'expiry' | 'cvc') => ({
...(type === 'number' ? { disableLink: true } : {}),

classes: {
base: 'form-control py-2',
focus: 'focus-visible',
invalid: 'is-invalid',
},

style: {
base: {
color: theme === Theme.Light ? '#262b38' : '#dbe2f0',
},
},
}),
[theme]
)
}

interface StripeCardDetailsProps {
onFocus?: () => void
className?: string
}

export const StripeCardDetails: React.FC<StripeCardDetailsProps> = ({ onFocus, className }) => {
const getOptions = useCardElementOptions()

return (
<div className={className}>
<div>
<Label className="d-block">
<Text className="mb-2">Card number</Text>
<CardNumberElement options={getOptions('number')} onFocus={onFocus} />
</Label>
</div>

<Grid columnCount={2} className="mt-3 mb-0 pb-3">
<Label className="d-block">
<Text className="mb-2">Expiry date</Text>
<CardExpiryElement options={getOptions('expiry')} onFocus={onFocus} />
</Label>

<Label className="d-block">
<Text className="mb-2">CVC</Text>
<CardCvcElement options={getOptions('cvc')} onFocus={onFocus} />
</Label>
</Grid>
</div>
)
}
Loading

0 comments on commit 4774879

Please sign in to comment.