-
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. 🤷
@@ -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}`) |
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.
<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 { 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.
|
||
// NOTE: Call loadStripe outside a component’s render to avoid recreating the object. | ||
// 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:
createToken
Stripe API, based on the related design doc and decisions.callCodyProApi
handle 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
PaymentDetails
component there. It still works fine.