Files
speckle-server/packages/server/modules/gatekeeper/rest/billing.ts
T
Gergő Jedlicska af3857a209 gergo/web 2038 billing graphql api (#3379)
* feat(gatekeeper): add gatekeeper module feature flag

* feat(gatekeeper): add workspace pricing table domain

* feat(gatekeeper): add checkout session creation

* feat(gatekeeper): verify stripe signature

* wip(gatekeeper): checkout callbacks

* feat(gatekeeper): add unlimited and academia plan types

* refactor(envHelper): getStringFromEnv helper

* chore(gatekeeper): add future todos

* feat(gatekeeper): add productId to the subscription domain

* feat(gatekeeper): add in memory repositories

* feat(gatekeeper): add more errors

* feat(gatekeeper): complete checkout session service

* feat(gatekeeper): add stripe client implementation

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

* feat(gendo): fix not needing env vars if gendo module is not enabled

* feat(gatekeeper): require a license for billing

* chore(gatekeeper): cleanup before testing

* feat(gatekeeper): subscriptionData parsing model

* ci: add billing integration and gatekeeper modules to test config

* test(gatekeeper): add checkout service tests

* feat(gatekeeper): make completeCheckout callback idempotent properly

* feat(gatekeeper): move to knex based repositories

* test(gatekeeper): billing repository tests

* feat(gatekeeper): add yearly billing cycle toggle

* feat(ci): add stripe integration context to test job

* feat(billingPage): conditionally render the checkout CTAs

* fix(gatekeeper): remove flaky test condition

* feat(helm): add billing integration feature flag

* WIP billing gql api

* feat(gatekeeper): cancel checkout session api

* feat(gatekeeper): handle existing checkout sessions, when trying to create a new one

* feat(gatekeeper): add workspace plans gql api

* feat(gatekeeper): handle cancelation and subscription updates

* fix(gatekeeper): scope initialization

* fix(gatekeeper): eliminate stripe client import sideeffect

* fix(gatekeeper): eliminate stripe client import sideeffect 2

* fix(mainConstants): fitler gatekeeper scopes with feature flag
2024-10-25 10:46:09 +02:00

258 lines
8.8 KiB
TypeScript

import { Router } from 'express'
import { validateRequest } from 'zod-express'
import { z } from 'zod'
import { authorizeResolver, validateScopes } from '@/modules/shared'
import { ensureError, Roles, Scopes } from '@speckle/shared'
import { Stripe } from 'stripe'
import {
getFrontendOrigin,
getStripeEndpointSigningKey
} from '@/modules/shared/helpers/envHelper'
import {
paidWorkspacePlans,
workspacePlanBillingIntervals
} from '@/modules/gatekeeper/domain/workspacePricing'
import {
countWorkspaceRoleWithOptionalProjectRoleFactory,
getWorkspaceFactory
} from '@/modules/workspaces/repositories/workspaces'
import { db } from '@/db/knex'
import {
completeCheckoutSessionFactory,
startCheckoutSessionFactory
} from '@/modules/gatekeeper/services/checkout'
import { WorkspaceNotFoundError } from '@/modules/workspaces/errors/workspace'
import {
createCheckoutSessionFactory,
createCustomerPortalUrlFactory,
getSubscriptionDataFactory,
parseSubscriptionData
} from '@/modules/gatekeeper/clients/stripe'
import {
deleteCheckoutSessionFactory,
getCheckoutSessionFactory,
getWorkspaceCheckoutSessionFactory,
getWorkspacePlanFactory,
getWorkspaceSubscriptionFactory,
saveCheckoutSessionFactory,
upsertWorkspaceSubscriptionFactory,
updateCheckoutSessionStatusFactory,
upsertPaidWorkspacePlanFactory,
getWorkspaceSubscriptionBySubscriptionIdFactory
} from '@/modules/gatekeeper/repositories/billing'
import { WorkspaceAlreadyPaidError } from '@/modules/gatekeeper/errors/billing'
import { withTransaction } from '@/modules/shared/helpers/dbHelper'
import { getStripeClient, getWorkspacePlanPrice } from '@/modules/gatekeeper/stripe'
import { handleSubscriptionUpdateFactory } from '@/modules/gatekeeper/services/subscriptions'
export const getBillingRouter = (): Router => {
const router = Router()
// 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 })
if (!workspace) throw new WorkspaceNotFoundError()
await validateScopes(req.context.scopes, Scopes.Gatekeeper.WorkspaceBilling)
await authorizeResolver(
req.context.userId,
workspaceId,
Roles.Workspace.Admin,
req.context.resourceAccessRules
)
const createCheckoutSession = createCheckoutSessionFactory({
stripe: getStripeClient(),
frontendOrigin: getFrontendOrigin(),
getWorkspacePlanPrice
})
const countRole = countWorkspaceRoleWithOptionalProjectRoleFactory({ db })
const session = await startCheckoutSessionFactory({
getWorkspaceCheckoutSession: getWorkspaceCheckoutSessionFactory({ db }),
getWorkspacePlan: getWorkspacePlanFactory({ db }),
countRole,
createCheckoutSession,
saveCheckoutSession: saveCheckoutSessionFactory({ db }),
deleteCheckoutSession: deleteCheckoutSessionFactory({ db })
})({ workspacePlan, workspaceId, workspaceSlug: workspace.slug, billingInterval })
req.res?.redirect(session.url)
}
)
router.get(
'/api/v1/billing/workspaces/:workspaceId/customer-portal',
validateRequest({
params: z.object({
workspaceId: z.string().min(1)
})
}),
async (req) => {
const { workspaceId } = req.params
await authorizeResolver(
req.context.userId,
workspaceId,
Roles.Workspace.Admin,
req.context.resourceAccessRules
)
const workspaceSubscription = await getWorkspaceSubscriptionFactory({ db })({
workspaceId
})
if (!workspaceSubscription) return null
const workspace = await getWorkspaceFactory({ db })({ workspaceId })
if (!workspace)
throw new Error('This cannot be, if there is a sub, there is a workspace')
const stripe = getStripeClient()
const url = await createCustomerPortalUrlFactory({
stripe,
frontendOrigin: getFrontendOrigin()
})({
workspaceId: workspaceSubscription.workspaceId,
workspaceSlug: workspace.slug,
customerId: workspaceSubscription.subscriptionData.customerId
})
return req.res?.redirect(url)
}
)
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 stripe = getStripeClient()
let event: Stripe.Event
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
}
switch (event.type) {
case 'checkout.session.async_payment_failed':
// if payment fails, we delete the failed session
await deleteCheckoutSessionFactory({ db })({
checkoutSessionId: event.data.object.id
})
break
case 'checkout.session.async_payment_succeeded':
case 'checkout.session.completed':
const session = event.data.object
if (!session.subscription)
return res.status(400).send('We only support subscription type checkouts')
switch (session.payment_status) {
case 'no_payment_required':
// we do not need to support this status
break
case '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 subscriptionId =
typeof session.subscription === 'string'
? session.subscription
: session.subscription.id
// this must use a transaction
const trx = await db.transaction()
const completeCheckout = completeCheckoutSessionFactory({
getCheckoutSession: getCheckoutSessionFactory({ db: trx }),
updateCheckoutSessionStatus: updateCheckoutSessionStatusFactory({
db: trx
}),
upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db: trx }),
upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({
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
case 'unpaid':
// if payment fails, we delete the failed session
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
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
await handleSubscriptionUpdateFactory({
getWorkspacePlan: getWorkspacePlanFactory({ db }),
upsertPaidWorkspacePlan: upsertPaidWorkspacePlanFactory({ db }),
getWorkspaceSubscriptionBySubscriptionId:
getWorkspaceSubscriptionBySubscriptionIdFactory({ db }),
upsertWorkspaceSubscription: upsertWorkspaceSubscriptionFactory({ db })
})({ subscriptionData: parseSubscriptionData(event.data.object) })
break
default:
break
}
res.status(200).send('ok')
})
return router
}