Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

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

Merged
merged 15 commits into from
Jun 12, 2024

Conversation

vdavid
Copy link
Contributor

@vdavid vdavid commented Jun 11, 2024

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:

  • Extracted the components from PaymentDetails for reuse. Note: I accidentally extracted two components that I didn't end up needing (heads up @taras-yemets), but the new structure felt tidier, so I kept it this way.
  • I now use the createToken Stripe API, based on the related design doc and decisions.
  • For the team creation, I use the new endpoint added by @chrsmith in https://github.com/sourcegraph/self-serve-cody/pull/864
  • I made callCodyProApi handle text responses because the endpoint gives an empty response, which resulted in an error when we tried to parse it as JSON.
  • A few loosely related warning and spelling fixes

Caveats:

Test plan

  • I've tested the checkout flow in the dark and light themes, to make sure it works and looks right. 1-min Loom | light theme screenshot
  • I've tested the Manage subscription page because I touched the PaymentDetails component there. It still works fine.

@cla-bot cla-bot bot added the cla-signed label Jun 11, 2024
Comment on lines +45 to +47
export function previewCreateTeam(requestBody: types.PreviewCreateTeamRequest): Call<types.PreviewResult> {
return { method: 'POST', urlSuffix: '/team/preview', requestBody }
}
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. 🤷

@@ -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.

Comment on lines 56 to 60
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.

Comment on lines 57 to 60
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.

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.

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.

<stop stopColor="#00DBFF" style={{ stopColor: '#00DBFF', stopOpacity: 1 }} />
<stop stopColor="#00DBFF" style={{ stopColor: '#00dbff', stopOpacity: 1 }} />
Copy link
Contributor Author

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

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

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'
Copy link
Contributor Author

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'] })
Copy link
Contributor Author

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.

@vdavid vdavid marked this pull request as ready for review June 11, 2024 20:55
Comment on lines 37 to 39
<div className={styles.gridItem}>
<PaymentMethod paymentMethod={subscription.paymentMethod} />
</div>
Copy link
Contributor

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

Copy link
Contributor

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

Copy link
Contributor Author

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

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?

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'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
Copy link
Contributor

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.

Suggested change
const creatingTeamMutation = initialSeatCount > 1
const shouldCreateTeamMutation = initialSeatCount > 1

Copy link
Contributor Author

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({
Copy link
Contributor

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

Copy link
Contributor Author

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.

Copy link
Contributor Author

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Copy link
Contributor

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

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.

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Comment on lines +139 to +141
} catch (error) {
setErrorMessage(`We couldn't create the Stripe token. This is what happened: ${error}`)
setSubmitting(false)
Copy link
Contributor

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?

Copy link
Contributor Author

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.

Comment on lines +207 to +211
{submitting ? (
<LoadingSpinner className={styles.lineHeightLoadingSpinner} />
) : (
'Subscribe'
)}
Copy link
Contributor

Choose a reason for hiding this comment

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

Copy link
Contributor Author

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.

Copy link
Contributor

@taras-yemets taras-yemets left a 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.

@vdavid vdavid enabled auto-merge (squash) June 12, 2024 09:48
@vdavid vdavid merged commit 4774879 into main Jun 12, 2024
11 checks passed
@vdavid vdavid deleted the dv/use-stripe-create-token branch June 12, 2024 09:57
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Projects
None yet
Development

Successfully merging this pull request may close these issues.

Get redirected to Stripe at the end of the checkout flow
2 participants