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

feat(plg): Add new Checkout flow that uses Stripe's createToken API #63213

Merged
merged 15 commits into from
Jun 12, 2024
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 }
}
Comment on lines +45 to +47
Copy link
Contributor Author

@vdavid vdavid Jun 11, 2024

Choose a reason for hiding this comment

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

I don't use this yet (nor the other preview* function above), but it felt wrong to remove them once I added them incl. the related types. 🤷


// Stripe Checkout

export function createStripeCheckoutSession(
Expand Down
11 changes: 9 additions & 2 deletions client/web/src/cody/management/api/react-query/callCodyProApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,15 @@ export const callCodyProApi = async <Data>(call: Call<Data>): Promise<Data | und

// 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 Error(`Request to Cody Pro API failed with status ${response.status}: ${response.statusText}`)
Copy link
Contributor Author

Choose a reason for hiding this comment

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

It might be helpful to see the status text as well? I can revert if not.

Copy link
Contributor

Choose a reason for hiding this comment

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

I faced a similar issue on the PR I'm working on.
As an alternative approach we can consider having an error class extending Error.
E.g.,

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

// then in callCodyProApi function or a custom hook around react-query useQuery/useMutation 
throw new CodyProApiError(await response.text(), response.status)

// somewhere in component
if (error instanceof CodyProApiError && error.status === 404) {
  // handle error
}

This way we can use error message and status separately and add more fields/methods to the CodyProApiError class if we need in the future.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Addressed in https://github.com/sourcegraph/sourcegraph/pull/63213/commits/3eda37a7448a94684dfdf223a1dffba98d8d91b2, although we don't currently process this error anywhere.

}

return (await response.json()) as Data
const responseText = await response.text()

// Handle both JSON and text responses.
try {
return JSON.parse(responseText) as Data
} catch {
return responseText as Data
}
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Note: the caller needs to know whether they want plain text or JSON. So far, I had no great idea how to make this better and frankly, we have 0 callers that use a text output, so I only needed this to avoid getting an error here upon an empty response.

Copy link
Contributor

Choose a reason for hiding this comment

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

As an alternative, we can make the callCodyProApi function return Response and then process it in a caller, e.g., parse as JSON, plain text, or ignore the return value in case of an empty response body.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

}
21 changes: 20 additions & 1 deletion 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 Down Expand Up @@ -45,3 +51,16 @@ export const useUpdateCurrentSubscription = (): UseMutationResult<
},
})
}

export const useCreateTeam = (): UseMutationResult<string | undefined, Error, CreateTeamRequest> => {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async requestBody => callCodyProApi(Client.createTeam(requestBody)),
onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.all }),
})
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@taras-yemets, I appreciate your 👀 here because you've spent more time with the use-query docs than I have.

}

export const usePreviewCreateTeam = (): UseMutationResult<PreviewResult | undefined, Error, PreviewCreateTeamRequest> =>
useMutation({
mutationFn: async requestBody => callCodyProApi(Client.previewCreateTeam(requestBody)),
})
22 changes: 22 additions & 0 deletions client/web/src/cody/management/api/teamSubscriptions.ts
Original file line number Diff line number Diff line change
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
}
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 be super great if these were generated, btw.

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 { Subscription } from '../api/teamSubscriptions'

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) => {
Copy link
Contributor Author

@vdavid vdavid Jun 11, 2024

Choose a reason for hiding this comment

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

@taras-yemets, I made this a function because we were getting warnings from Stripe in the JS console because we were passing disableLink (and hidePostalCode!) to the wrong components.

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
Loading