fix(gatekeeper): fix billing router initialization (#3349)

This commit is contained in:
Gergő Jedlicska 2024-10-21 13:48:25 +02:00 коммит произвёл GitHub
Родитель 4b4963a75e
Коммит b7fd1208f0
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
2 изменённых файлов: 128 добавлений и 125 удалений

Просмотреть файл

@ -2,7 +2,7 @@ import { moduleLogger } from '@/logging/logging'
import { SpeckleModule } from '@/modules/shared/helpers/typeHelper'
import { getFeatureFlags } from '@/modules/shared/helpers/envHelper'
import { validateModuleLicense } from '@/modules/gatekeeper/services/validateLicense'
import billingRouter from '@/modules/gatekeeper/rest/billing'
import { getBillingRouter } from '@/modules/gatekeeper/rest/billing'
const { FF_GATEKEEPER_MODULE_ENABLED, FF_BILLING_INTEGRATION_ENABLED } =
getFeatureFlags()
@ -24,7 +24,7 @@ const gatekeeperModule: SpeckleModule = {
if (isInitial) {
// TODO: need to subscribe to the workspaceCreated event and store the workspacePlan as a trial if billing enabled, else store as unlimited
if (FF_BILLING_INTEGRATION_ENABLED) {
app.use(billingRouter)
app.use(getBillingRouter())
const isLicenseValid = await validateModuleLicense({
requiredModules: ['billing']

Просмотреть файл

@ -44,12 +44,6 @@ import { GetWorkspacePlanPrice } from '@/modules/gatekeeper/domain/billing'
import { WorkspaceAlreadyPaidError } from '@/modules/gatekeeper/errors/billing'
import { withTransaction } from '@/modules/shared/helpers/dbHelper'
const router = Router()
export default router
const stripe = new Stripe(getStripeApiKey(), { typescript: true })
const workspacePlanPrices = (): Record<
WorkspacePricingPlans,
Record<WorkspacePlanBillingIntervals, string> & { productId: string }
@ -81,144 +75,153 @@ const getWorkspacePlanPrice: GetWorkspacePlanPrice = ({
billingInterval
}) => workspacePlanPrices()[workspacePlan][billingInterval]
// this prob needs to be turned into a GQL resolver for better frontend integration for errors
router.get(
'/api/v1/billing/workspaces/:workspaceId/checkout-session/:workspacePlan/:billingInterval',
validateRequest({
params: z.object({
workspaceId: z.string().min(1),
workspacePlan: paidWorkspacePlans,
billingInterval: workspacePlanBillingIntervals
})
}),
async (req) => {
const { workspaceId, workspacePlan, billingInterval } = req.params
const workspace = await getWorkspaceFactory({ db })({ workspaceId })
export const getBillingRouter = (): Router => {
const router = Router()
if (!workspace) throw new WorkspaceNotFoundError()
const stripe = new Stripe(getStripeApiKey(), { typescript: true })
await authorizeResolver(
req.context.userId,
workspaceId,
Roles.Workspace.Admin,
req.context.resourceAccessRules
)
// this prob needs to be turned into a GQL resolver for better frontend integration for errors
router.get(
'/api/v1/billing/workspaces/:workspaceId/checkout-session/:workspacePlan/:billingInterval',
validateRequest({
params: z.object({
workspaceId: z.string().min(1),
workspacePlan: paidWorkspacePlans,
billingInterval: workspacePlanBillingIntervals
})
}),
async (req) => {
const { workspaceId, workspacePlan, billingInterval } = req.params
const workspace = await getWorkspaceFactory({ db })({ workspaceId })
const createCheckoutSession = createCheckoutSessionFactory({
stripe,
frontendOrigin: getFrontendOrigin(),
getWorkspacePlanPrice
})
if (!workspace) throw new WorkspaceNotFoundError()
const countRole = countWorkspaceRoleWithOptionalProjectRoleFactory({ db })
await authorizeResolver(
req.context.userId,
workspaceId,
Roles.Workspace.Admin,
req.context.resourceAccessRules
)
const session = await startCheckoutSessionFactory({
getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory({ db }),
getWorkspacePlan: getWorkspacePlanFactory({ db }),
countRole,
createCheckoutSession,
saveCheckoutSession: saveCheckoutSessionFactory({ db })
})({ workspacePlan, workspaceId, workspaceSlug: workspace.slug, billingInterval })
const createCheckoutSession = createCheckoutSessionFactory({
stripe,
frontendOrigin: getFrontendOrigin(),
getWorkspacePlanPrice
})
req.res?.redirect(session.url)
}
)
const countRole = countWorkspaceRoleWithOptionalProjectRoleFactory({ db })
router.post('/api/v1/billing/webhooks', async (req, res) => {
const endpointSecret = getStripeEndpointSigningKey()
const sig = req.headers['stripe-signature']
if (!sig) {
res.status(400).send('Missing payload signature')
return
}
const session = await startCheckoutSessionFactory({
getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory({ db }),
getWorkspacePlan: getWorkspacePlanFactory({ db }),
countRole,
createCheckoutSession,
saveCheckoutSession: saveCheckoutSessionFactory({ db })
})({ workspacePlan, workspaceId, workspaceSlug: workspace.slug, billingInterval })
let event: Stripe.Event
req.res?.redirect(session.url)
}
)
try {
event = stripe.webhooks.constructEvent(
// yes, the express json middleware auto parses the payload and stri need it in a string
req.body,
sig,
endpointSecret
)
} catch (err) {
res.status(400).send(`Webhook Error: ${ensureError(err).message}`)
return
}
router.post('/api/v1/billing/webhooks', async (req, res) => {
const endpointSecret = getStripeEndpointSigningKey()
const sig = req.headers['stripe-signature']
if (!sig) {
res.status(400).send('Missing payload signature')
return
}
switch (event.type) {
case 'checkout.session.async_payment_failed':
// TODO: need to alert the user and delete the session ?
break
case 'checkout.session.async_payment_succeeded':
case 'checkout.session.completed':
const session = event.data.object
let event: Stripe.Event
if (!session.subscription)
return res.status(400).send('We only support subscription type checkouts')
try {
event = stripe.webhooks.constructEvent(
// yes, the express json middleware auto parses the payload and stri need it in a string
req.body,
sig,
endpointSecret
)
} catch (err) {
res.status(400).send(`Webhook Error: ${ensureError(err).message}`)
return
}
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!
// get checkout session from the DB, if not found CONTACT SUPPORT!!!
// if the session is already paid, means, we've already settled this checkout, and this is a webhook recall
// set checkout state to paid
// go ahead and provision the plan
// store customer id and subscription Id associated to the workspace plan
switch (event.type) {
case 'checkout.session.async_payment_failed':
// TODO: need to alert the user and delete the session ?
break
case 'checkout.session.async_payment_succeeded':
case 'checkout.session.completed':
const session = event.data.object
const subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription.id
if (!session.subscription)
return res.status(400).send('We only support subscription type checkouts')
// this must use a transaction
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!
// get checkout session from the DB, if not found CONTACT SUPPORT!!!
// if the session is already paid, means, we've already settled this checkout, and this is a webhook recall
// set checkout state to paid
// go ahead and provision the plan
// store customer id and subscription Id associated to the workspace plan
const trx = await db.transaction()
const subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription.id
const completeCheckout = completeCheckoutSessionFactory({
getCheckoutSession: getCheckoutSessionFactory({ db: trx }),
updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory({ db: trx }),
upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db: trx }),
saveWorkspaceSubscription: saveWorkspaceSubscriptionFactory({ db: trx }),
getSubscriptionData: getSubscriptionDataFactory({
stripe
})
})
// this must use a transaction
try {
await withTransaction(
completeCheckout({
sessionId: session.id,
subscriptionId
const trx = await db.transaction()
const completeCheckout = completeCheckoutSessionFactory({
getCheckoutSession: getCheckoutSessionFactory({ db: trx }),
updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory({
db: trx
}),
trx
)
} catch (err) {
if (err instanceof WorkspaceAlreadyPaidError) {
// ignore the request, this is prob a replay from stripe
} else {
throw err
upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db: trx }),
saveWorkspaceSubscription: saveWorkspaceSubscriptionFactory({ db: trx }),
getSubscriptionData: getSubscriptionDataFactory({
stripe
})
})
try {
await withTransaction(
completeCheckout({
sessionId: session.id,
subscriptionId
}),
trx
)
} catch (err) {
if (err instanceof WorkspaceAlreadyPaidError) {
// ignore the request, this is prob a replay from stripe
} else {
throw err
}
}
}
}
break
break
case 'checkout.session.expired':
// delete the checkout session from the DB
await deleteCheckoutSessionFactory({ db })({
checkoutSessionId: event.data.object.id
})
break
case 'checkout.session.expired':
// delete the checkout session from the DB
await deleteCheckoutSessionFactory({ db })({
checkoutSessionId: event.data.object.id
})
break
default:
break
}
default:
break
}
res.status(200).send('ok')
})
res.status(200).send('ok')
})
// prob needed when the checkout is cancelled
router.delete(
'/api/v1/billing/workspaces/:workspaceSlug/checkout-session/:workspacePlan'
)
// prob needed when the checkout is cancelled
router.delete(
'/api/v1/billing/workspaces/:workspaceSlug/checkout-session/:workspacePlan'
)
return router
}