feat(gatekeeper): add checkout session completion webhook callback path

This commit is contained in:
Gergő Jedlicska
2024-10-17 07:31:34 +02:00
parent 6ae4b5d2da
commit e7bfa387e8
@@ -2,20 +2,17 @@ import { Router } from 'express'
import { validateRequest } from 'zod-express'
import { z } from 'zod'
import { authorizeResolver } from '@/modules/shared'
import { ensureError, Roles, throwUncoveredError } from '@speckle/shared'
import { ensureError, Roles } from '@speckle/shared'
import { Stripe } from 'stripe'
import {
getFrontendOrigin,
getStringFromEnv,
getStripeApiKey,
getStripeEndpointSigningKey,
getWorkspaceBusinessSeatStripePriceId,
getWorkspaceGuestSeatStripePriceId,
getWorkspaceProSeatStripePriceId,
getWorkspaceTeamSeatStripePriceId
getStripeEndpointSigningKey
} from '@/modules/shared/helpers/envHelper'
import {
WorkspacePlanBillingIntervals,
workspacePlans,
paidWorkspacePlans,
WorkspacePricingPlans
} from '@/modules/gatekeeper/domain/workspacePricing'
import {
@@ -23,13 +20,26 @@ import {
getWorkspaceBySlugFactory
} from '@/modules/workspaces/repositories/workspaces'
import { db } from '@/db/knex'
import { startCheckoutSessionFactory } from '@/modules/gatekeeper/services/workspaces'
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import { createCheckoutSessionFactory } from '@/modules/gatekeeper/clients/stripe'
import {
completeCheckoutSessionFactory,
startCheckoutSessionFactory
} from '@/modules/gatekeeper/services/workspaces'
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import {
createCheckoutSessionFactory,
getSubscriptionDataFactory
} from '@/modules/gatekeeper/clients/stripe'
import {
deleteCheckoutSessionFactory,
getCheckoutSessionFactory,
getWorkspaceCheckoutSessionFactory,
getWorkspacePlanFactory,
saveCheckoutSessionFactory
saveCheckoutSessionFactory,
saveWorkspaceSubscriptionFactory,
updateCheckoutSessionStatusFactory,
upsertPaidWorkspacePlanFactory
} from '@/modules/gatekeeper/repositories/billing'
import { GetWorkspacePlanPrice } from '@/modules/gatekeeper/domain/billing'
const router = Router()
@@ -37,33 +47,43 @@ export default router
const stripe = new Stripe(getStripeApiKey(), { typescript: true })
const getWorkspacePlanPrice = ({
workspacePlan
}: {
workspacePlan: WorkspacePricingPlans
billingInterval: WorkspacePlanBillingIntervals
}): string => {
// right now, ignoring interval
switch (workspacePlan) {
case 'team':
return getWorkspaceTeamSeatStripePriceId()
case 'pro':
return getWorkspaceProSeatStripePriceId()
case 'business':
return getWorkspaceBusinessSeatStripePriceId()
case 'guest':
return getWorkspaceGuestSeatStripePriceId()
default:
throwUncoveredError(workspacePlan)
const workspacePlanPrices = (): Record<
WorkspacePricingPlans,
Record<WorkspacePlanBillingIntervals, string> & { productId: string }
> => ({
guest: {
productId: getStringFromEnv('WORKSPACE_GUEST_SEAT_STRIPE_PRODUCT_ID'),
monthly: getStringFromEnv('WORKSPACE_MONTHLY_GUEST_SEAT_STRIPE_PRICE_ID'),
yearly: getStringFromEnv('WORKSPACE_YEARLY_GUEST_SEAT_STRIPE_PRICE_ID')
},
team: {
productId: getStringFromEnv('WORKSPACE_TEAM_SEAT_STRIPE_PRODUCT_ID'),
monthly: getStringFromEnv('WORKSPACE_MONTHLY_TEAM_SEAT_STRIPE_PRICE_ID'),
yearly: getStringFromEnv('WORKSPACE_YEARLY_TEAM_SEAT_STRIPE_PRICE_ID')
},
pro: {
productId: getStringFromEnv('WORKSPACE_PRO_SEAT_STRIPE_PRODUCT_ID'),
monthly: getStringFromEnv('WORKSPACE_MONTHLY_PRO_SEAT_STRIPE_PRICE_ID'),
yearly: getStringFromEnv('WORKSPACE_YEARLY_PRO_SEAT_STRIPE_PRICE_ID')
},
business: {
productId: getStringFromEnv('WORKSPACE_BUSINESS_SEAT_STRIPE_PRODUCT_ID'),
monthly: getStringFromEnv('WORKSPACE_MONTHLY_BUSINESS_SEAT_STRIPE_PRICE_ID'),
yearly: getStringFromEnv('WORKSPACE_YEARLY_BUSINESS_SEAT_STRIPE_PRICE_ID')
}
}
})
const getWorkspacePlanPrice: GetWorkspacePlanPrice = ({
workspacePlan,
billingInterval
}) => workspacePlanPrices()[workspacePlan][billingInterval]
router.get(
'/api/v1/billing/workspaces/:workspaceSlug/checkout-session/:workspacePlan',
validateRequest({
params: z.object({
workspaceSlug: z.string().min(1),
workspacePlan: workspacePlans
workspacePlan: paidWorkspacePlans
})
}),
async (req) => {
@@ -88,6 +108,7 @@ router.get(
const countRole = countWorkspaceRoleWithOptionalProjectRoleFactory({ db })
const session = await startCheckoutSessionFactory({
getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory(),
getWorkspacePlan: getWorkspacePlanFactory(),
countRole,
createCheckoutSession,
@@ -98,34 +119,6 @@ router.get(
}
)
// const fulfillCheckoutFactory =
// ({ stripe }: { stripe: Stripe }) =>
// async ({ sessionId }: { sessionId: string }) => {
// // Set your secret key. Remember to switch to your live secret key in production.
// // See your keys here: https://dashboard.stripe.com/apikeys
// console.log('Fulfilling Checkout Session ' + sessionId)
// // TODO: Make this function safe to run multiple times,
// // even concurrently, with the same session ID
// // TODO: Make sure fulfillment hasn't already been
// // peformed for this Checkout Session
// // Retrieve the Checkout Session from the API with line_items expanded
// const checkoutSession = await stripe.checkout.sessions.retrieve(sessionId, {
// expand: ['line_items']
// })
// // Check the Checkout Session's payment_status property
// // to determine if fulfillment should be peformed
// if (checkoutSession.payment_status !== 'unpaid') {
// // TODO: Perform fulfillment of the line items
// // TODO: Record/save fulfillment status for this
// // Checkout Session
// }
// }
router.post('/api/v1/billing/webhooks', async (req, res) => {
const endpointSecret = getStripeEndpointSigningKey()
const sig = req.headers['stripe-signature']
@@ -156,9 +149,10 @@ router.post('/api/v1/billing/webhooks', async (req, res) => {
case 'checkout.session.completed':
const session = event.data.object
session.subscription
if (!session.subscription)
return res.status(400).send('We only support subscription type checkouts')
if (session.payment_status !== 'unpaid') {
if (session.payment_status === 'paid') {
// If the workspace is already on a paid plan, we made a bo bo.
// existing subs should be updated via the api, not pushed through the checkout sess again
// the start checkout endpoint should guard this!
@@ -167,11 +161,35 @@ router.post('/api/v1/billing/webhooks', async (req, res) => {
// set checkout state to paid
// go ahead and provision the plan
// store customer id and subscription Id associated to the workspace plan
}
// move the workspace plan to the new plan
const subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription.id
// this must use a transaction
const completeCheckout = completeCheckoutSessionFactory({
getCheckoutSession: getCheckoutSessionFactory(),
updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory(),
upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory(),
saveWorkspaceSubscription: saveWorkspaceSubscriptionFactory(),
getSubscriptionData: getSubscriptionDataFactory({
stripe
})
})
await completeCheckout({
sessionId: session.id,
subscriptionId
})
}
break
case 'checkout.session.expired':
// delete the checkout session from the DB
// delete the checkout session from the DB
await deleteCheckoutSessionFactory()({ checkoutSessionId: event.data.object.id })
break
default:
break
}