- 
                Notifications
    You must be signed in to change notification settings 
- Fork 1.3k
          feat(plg): Add new Checkout flow that uses Stripe's createToken API
          #63213
        
      Conversation
| export function previewCreateTeam(requestBody: types.PreviewCreateTeamRequest): Call<types.PreviewResult> { | ||
| return { method: 'POST', urlSuffix: '/team/preview', requestBody } | ||
| } | 
There was a problem hiding this comment.
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. 🤷
| // 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}`) | 
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
| try { | ||
| return JSON.parse(responseText) as Data | ||
| } catch { | ||
| return responseText as Data | ||
| } | 
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
| return useMutation({ | ||
| mutationFn: async requestBody => callCodyProApi(Client.createTeam(requestBody)), | ||
| onSuccess: () => queryClient.invalidateQueries({ queryKey: queryKeys.all }), | ||
| }) | 
There was a problem hiding this comment.
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.
| seats: number | ||
| billingInterval: BillingInterval | ||
| couponCode?: string | ||
| } | 
There was a problem hiding this comment.
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.
| import { useTheme, Theme } from '@sourcegraph/shared/src/theme' | ||
| import { Label, Text, Grid } from '@sourcegraph/wildcard' | ||
|  | ||
| const useCardElementOptions = (): ((type: 'number' | 'expiry' | 'cvc') => StripeCardElementOptions) => { | 
There was a problem hiding this comment.
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.
| gradientTransform="translate(23.4233 -44.4873) rotate(77.074) scale(58.2161)" | ||
| > | ||
| <stop stopColor="#00DBFF" style={{ stopColor: '#00DBFF', stopOpacity: 1 }} /> | ||
| <stop stopColor="#00DBFF" style={{ stopColor: '#00dbff', stopOpacity: 1 }} /> | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Super unimportant, but my IDE did it and it felt neater after.
|  | ||
| import type { Subscription } from '../../api/teamSubscriptions' | ||
|  | ||
| export const NonEditableBillingAddress: React.FC<{ subscription: Subscription }> = ({ | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I will use this when I add the "Add seats" feature tomorrow-ish!
| void updateSeatCount() | ||
| }, [firstLineItemId, debouncedSeatCount, updateLineItemQuantity]) | ||
| // N * $9. We expect this to be more complex later with annual plans, etc. | ||
| const total = seatCount * 9 | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
@chrsmith suggested using a function for this, so I did that first, but it felt overengineered for a simple multiplication, so I inlined the calculation and left this note instead. We can extract it any time.
| import { defaultCodyProApiClientContext, CodyProApiClientContext } from '../../api/components/CodyProApiClient' | ||
| import { useBillingAddressStripeElementsOptions } from '../manage/BillingAddress' | ||
|  | ||
| import { CodyProCheckoutFormContainer } from './CodyProCheckoutFormContainer' | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Both CodyProCheckoutFormContainer and PayButton lost so much logic that I just put everything in CodyProCheckoutForm for now.
| // We do it here, meaning that "stripe.js" will get loaded lazily, when the user | ||
| // routes to this page. | ||
| const publishableKey = window.context.frontendCodyProConfig?.stripePublishableKey | ||
| const stripe = await stripeJs.loadStripe(publishableKey || '', { betas: ['custom_checkout_beta_2'] }) | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Not needing the beta anymore. We probably want to remove this from the back end as well—I'll create a PR soon after this, or we'll do it later.
| <div className={styles.gridItem}> | ||
| <PaymentMethod paymentMethod={subscription.paymentMethod} /> | ||
| </div> | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: we can wrap PaymentMethod with Elements here (same as we do for BillingAddress
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, I see, we might not need Elements when payment method is missing
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
That's true, but I think you were right in seeing that the diff between the two branches was weird. WDYT about https://github.com/sourcegraph/sourcegraph/pull/63213/commits/88110af228bd88d5abfafe6c4fd73ab6be6028fa? It needed passing an extra prop, but the <Elements> use is more specific to where we actually need it.
| } | ||
| setUpdatingSeatCount(false) | ||
| } | ||
| const [seatCount, setSeatCount] = React.useState(initialSeatCount) | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can initialSeatCount prop change? Do we need to sync state with props in this case?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It's controlled by a query param, and it cannot change.
| const navigate = useNavigate() | ||
|  | ||
| const { total, lineItems, updateLineItemQuantity, email, updateEmail, status } = useCustomCheckout() | ||
| const creatingTeamMutation = initialSeatCount > 1 | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: "creating" sounds like pending process to me.
| const creatingTeamMutation = initialSeatCount > 1 | |
| const shouldCreateTeamMutation = initialSeatCount > 1 | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks, was bad naming on my part. I ended up going with just isTeam: https://github.com/sourcegraph/sourcegraph/pull/63213/commits/a09ff2f7da2083af0c5f59af4a9b9e6f61dba32e
|  | ||
| // This is where we send the token to the backend to create a subscription. | ||
| try { | ||
| await createTeamMutation.mutateAsync({ | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As we don't do anything with the returned promise, it's recommended to use mutate instead of mutateAsync
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ah, true. I meant to use the result, that's why I did it, but then I didn't use it. https://github.com/sourcegraph/sourcegraph/pull/63213/commits/cccb7510d25cd7c5f7a4819fe34731f9a60b61c5 fixes this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, no, wait, I did it this way to make sure we wait for the result. I wonder if .mutate executes synchronously. I want to avoid navigating away too early. I'll test this.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I actually reverted this and added a comment about this: https://github.com/sourcegraph/sourcegraph/pull/63213/commits/9eb57d13a4a3fd5d30f9b948c6c67484bf960847
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We can navigate if the mutation was successful using the onSuccess callback.
createTeamMutation.mutate(data, {onSuccess: () => navigate('/cody/manage?welcome=1')})
and derive the error and loading state from createTeamMutation which will result in less local state: we won't need extra setErrorMessage for example:
// somewhere in component body
const errorMsg = stripeErrorMessageFromState || (createTeamMutation.isError ? `We couldn't create the team. This is what happened: ${createTeamMutation.error.message}` : "")
But I agree that mutateAsync also works in this case.
| }) | ||
| } catch (error) { | ||
| setErrorMessage(`We couldn't create the team. This is what happened: ${error}`) | ||
| setSubmitting(false) | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We have two loading states: waiting for stripe.createToken and createTeamMutation.mutate promises to resolve. I suggest we split them and use like
const isLoading = isStripeLoading || createTeamMutation.isPending
for clarity.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I just went with mutateAsync (https://github.com/sourcegraph/sourcegraph/pull/63213/commits/9eb57d13a4a3fd5d30f9b948c6c67484bf960847) and kept the single loading var.
| } catch (error) { | ||
| setErrorMessage(`We couldn't create the Stripe token. This is what happened: ${error}`) | ||
| setSubmitting(false) | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As createTeamMutation.mutate has it's own catch block, can we move this one closer to stripe.createToken call?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Did it in https://github.com/sourcegraph/sourcegraph/pull/63213/commits/846db9919fd380e52942ca142d8680f1786f258c, and also extracted the Stripe checkout functionality for clarity.
| {submitting ? ( | ||
| <LoadingSpinner className={styles.lineHeightLoadingSpinner} /> | ||
| ) : ( | ||
| 'Subscribe' | ||
| )} | 
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LoadingIconButton may serve likely solves a similar problem.
https://github.com/sourcegraph/sourcegraph/blob/dd42f20620c629c175e24a75ced4100f2344c601/client/web/src/cody/management/subscription/manage/LoadingIconButton.tsx#L10
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I've just tried it, but that component always displays an icon on the button, which we don't want here. It doesn't allow a null icon either. I'm leaving this as is for now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
LGTM.
Left a few non-blocking comments inline.
I won't elaborate on the problem because I already did in the issue. Please read its description for more info.
To code reviewers: Best to review this commit by commit. The PR is not huge as a whole, but you'll save a lot on the complexity if you go commit by commit. I did my best to commit this in atomic pieces.
What I do here is:
createTokenStripe API, based on the related design doc and decisions.callCodyProApihandle text responses because the endpoint gives an empty response, which resulted in an error when we tried to parse it as JSON.Caveats:
Test plan
PaymentDetailscomponent there. It still works fine.