feat(gatekeeper): add checkout session completion webhook callback path
This commit is contained in:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user